Skip to content

Commit fed40eb

Browse files
committed
Add CUSTOM_NETGROUP_* traits and Nova network-group-affinity patch
Extends the LLDP inspection hook to add CUSTOM_NETGROUP_<name> traits to Ironic nodes for each '-network' VLAN group they are connected to. These traits are consumed by the new NetworkGroupAffinityFilter and NetworkGroupAntiAffinityFilter in Nova to constrain scheduling to nodes within a specific cabinet switch pair. Changes: - inspect_hook_update_baremetal_ports.py: adds _network_group_trait_name() function and includes CUSTOM_NETGROUP_* traits in _set_node_traits() - _is_our_trait() updated to manage both CUSTOM_*_SWITCH and CUSTOM_NETGROUP_* patterns - Nova patch (0002_network_group_affinity_policy.patch) added to containers/nova/patches/ for quilt application during image build - Tests updated and new test class added for trait functions
1 parent 1151cd2 commit fed40eb

4 files changed

Lines changed: 423 additions & 4 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
Add network-group-affinity and network-group-anti-affinity server group policies.
2+
3+
These new server group policies allow constraining instance placement to
4+
Ironic nodes within a specific physical network group (VLAN group / cabinet
5+
switch pair). The network group is specified in the server group's rules
6+
field at creation time and matched against CUSTOM_NETGROUP_* traits reported
7+
by Ironic nodes via the Placement service.
8+
9+
Usage:
10+
openstack server group create --policy network-group-affinity \
11+
--rule network_group=a1-1-network my-cabinet-group
12+
openstack server create --hint group=<group-uuid> ...
13+
14+
diff --git a/nova/scheduler/filters/network_group_filter.py b/nova/scheduler/filters/network_group_filter.py
15+
new file mode 100644
16+
index 0000000000..000000000a
17+
--- /dev/null
18+
+++ b/nova/scheduler/filters/network_group_filter.py
19+
@@ -0,0 +1,126 @@
20+
+# Copyright 2025 Rackspace Technology, Inc.
21+
+# All Rights Reserved.
22+
+#
23+
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
24+
+# not use this file except in compliance with the License. You may obtain
25+
+# a copy of the License at
26+
+#
27+
+# http://www.apache.org/licenses/LICENSE-2.0
28+
+#
29+
+# Unless required by applicable law or agreed to in writing, software
30+
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
31+
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
32+
+# License for the specific language governing permissions and limitations
33+
+# under the License.
34+
+
35+
+"""Scheduler filters for network group affinity and anti-affinity.
36+
+
37+
+These filters constrain instance placement based on the physical network
38+
+group (VLAN group / cabinet switch pair) that an Ironic node belongs to.
39+
+
40+
+The network group is specified in a server group's ``rules`` field at
41+
+creation time and is matched against ``CUSTOM_NETGROUP_*`` traits reported
42+
+by Ironic nodes via the Placement service.
43+
+"""
44+
+
45+
+from oslo_log import log as logging
46+
+
47+
+from nova.scheduler import filters
48+
+
49+
+LOG = logging.getLogger(__name__)
50+
+
51+
+# Prefix used when converting a network group name to a trait.
52+
+# Example: "a1-1-network" -> "CUSTOM_NETGROUP_A1_1_NETWORK"
53+
+_TRAIT_PREFIX = "CUSTOM_NETGROUP_"
54+
+
55+
+
56+
+def _network_group_to_trait(network_group):
57+
+ """Convert a network group name to its corresponding Placement trait.
58+
+
59+
+ :param network_group: The network group name (e.g. "a1-1-network")
60+
+ :returns: The trait string (e.g. "CUSTOM_NETGROUP_A1_1_NETWORK")
61+
+ """
62+
+ normalised = network_group.upper().replace("-", "_").replace("/", "_")
63+
+ return _TRAIT_PREFIX + normalised
64+
+
65+
+
66+
+class NetworkGroupAffinityFilter(filters.BaseHostFilter):
67+
+ """Schedule instances onto hosts within a specific network group.
68+
+
69+
+ When a server group has the ``network-group-affinity`` policy and a
70+
+ ``network_group`` rule, this filter only passes hosts whose reported
71+
+ traits include the matching ``CUSTOM_NETGROUP_*`` trait.
72+
+
73+
+ Hosts without the required trait are rejected.
74+
+ """
75+
+
76+
+ # The trait set of a host does not change within a single scheduling
77+
+ # request.
78+
+ run_filter_once_per_request = True
79+
+
80+
+ RUN_ON_REBUILD = False
81+
+
82+
+ def host_passes(self, host_state, spec_obj):
83+
+ instance_group = spec_obj.instance_group
84+
+ if not instance_group:
85+
+ return True
86+
+
87+
+ policy = instance_group.policy if instance_group else None
88+
+ if policy != 'network-group-affinity':
89+
+ return True
90+
+
91+
+ rules = instance_group.rules
92+
+ network_group = rules.get('network_group') if rules else None
93+
+ if not network_group:
94+
+ return True
95+
+
96+
+ required_trait = _network_group_to_trait(network_group)
97+
+
98+
+ host_traits = set()
99+
+ if hasattr(host_state, 'traits'):
100+
+ host_traits = host_state.traits
101+
+
102+
+ passes = required_trait in host_traits
103+
+ if not passes:
104+
+ LOG.debug(
105+
+ "NetworkGroupAffinityFilter: host %(host)s rejected. "
106+
+ "Required trait %(trait)s not found in host traits.",
107+
+ {'host': host_state.host, 'trait': required_trait})
108+
+ return passes
109+
+
110+
+
111+
+class NetworkGroupAntiAffinityFilter(filters.BaseHostFilter):
112+
+ """Schedule instances onto hosts NOT within a specific network group.
113+
+
114+
+ When a server group has the ``network-group-anti-affinity`` policy and
115+
+ a ``network_group`` rule, this filter rejects hosts whose reported
116+
+ traits include the matching ``CUSTOM_NETGROUP_*`` trait.
117+
+
118+
+ This is useful for spreading workloads across cabinets or ensuring
119+
+ instances avoid a particular switch pair.
120+
+ """
121+
+
122+
+ # The trait set of a host does not change within a single scheduling
123+
+ # request.
124+
+ run_filter_once_per_request = True
125+
+
126+
+ RUN_ON_REBUILD = False
127+
+
128+
+ def host_passes(self, host_state, spec_obj):
129+
+ instance_group = spec_obj.instance_group
130+
+ if not instance_group:
131+
+ return True
132+
+
133+
+ policy = instance_group.policy if instance_group else None
134+
+ if policy != 'network-group-anti-affinity':
135+
+ return True
136+
+
137+
+ rules = instance_group.rules
138+
+ network_group = rules.get('network_group') if rules else None
139+
+ if not network_group:
140+
+ return True
141+
+
142+
+ excluded_trait = _network_group_to_trait(network_group)
143+
+
144+
+ host_traits = set()
145+
+ if hasattr(host_state, 'traits'):
146+
+ host_traits = host_state.traits
147+
+
148+
+ passes = excluded_trait not in host_traits
149+
+ if not passes:
150+
+ LOG.debug(
151+
+ "NetworkGroupAntiAffinityFilter: host %(host)s rejected. "
152+
+ "Excluded trait %(trait)s found in host traits.",
153+
+ {'host': host_state.host, 'trait': excluded_trait})
154+
+ return passes
155+
diff --git a/nova/api/openstack/compute/schemas/server_groups.py b/nova/api/openstack/compute/schemas/server_groups.py
156+
index abcdef1234..1234abcdef 100644
157+
--- a/nova/api/openstack/compute/schemas/server_groups.py
158+
+++ b/nova/api/openstack/compute/schemas/server_groups.py
159+
@@ -63,14 +63,20 @@ create_v264 = copy.deepcopy(create_v215)
160+
del create_v264['properties']['server_group']['properties']['policies']
161+
create_v264['properties']['server_group']['required'].remove('policies')
162+
create_v264['properties']['server_group']['required'].append('policy')
163+
create_v264['properties']['server_group']['properties']['policy'] = {
164+
'type': 'string',
165+
- 'enum': ['anti-affinity', 'affinity',
166+
- 'soft-anti-affinity', 'soft-affinity'],
167+
+ 'enum': ['anti-affinity', 'affinity',
168+
+ 'soft-anti-affinity', 'soft-affinity',
169+
+ 'network-group-affinity', 'network-group-anti-affinity'],
170+
}
171+
172+
create_v264['properties']['server_group']['properties']['rules'] = {
173+
'type': 'object',
174+
'properties': {
175+
'max_server_per_host':
176+
parameter_types.positive_integer,
177+
+ 'network_group': {
178+
+ 'type': 'string',
179+
+ 'minLength': 1,
180+
+ 'maxLength': 255,
181+
+ },
182+
},
183+
'additionalProperties': False,
184+
}
185+
@@ -155,11 +161,13 @@ _server_group_response_v264['properties'].update({
186+
'policy': {
187+
'type': 'string',
188+
'enum': [
189+
'affinity',
190+
'anti-affinity',
191+
'soft-affinity',
192+
'soft-anti-affinity',
193+
+ 'network-group-affinity',
194+
+ 'network-group-anti-affinity',
195+
],
196+
},
197+
'rules': {
198+
'type': 'object',
199+
'properties': {
200+
'max_server_per_host': {'type': 'integer'},
201+
+ 'network_group': {'type': 'string'},
202+
},
203+
'required': [],
204+
'additionalProperties': False,
205+
diff --git a/nova/api/openstack/compute/server_groups.py b/nova/api/openstack/compute/server_groups.py
206+
index abcdef5678..5678abcdef 100644
207+
--- a/nova/api/openstack/compute/server_groups.py
208+
+++ b/nova/api/openstack/compute/server_groups.py
209+
@@ -236,13 +236,26 @@ class ServerGroupController(wsgi.Controller):
210+
if api_version_request.is_supported(req, "2.64"):
211+
policy = vals['policy']
212+
rules = vals.get('rules', {})
213+
- if policy != 'anti-affinity' and rules:
214+
- msg = _("Only anti-affinity policy supports rules.")
215+
- raise exc.HTTPBadRequest(explanation=msg)
216+
- # NOTE(yikun): This should be removed in Stein version.
217+
- if not _should_enable_custom_max_server_rules(context, rules):
218+
- msg = _("Creating an anti-affinity group with rule "
219+
- "max_server_per_host > 1 is not yet supported.")
220+
- raise exc.HTTPConflict(explanation=msg)
221+
+ if policy == 'anti-affinity':
222+
+ # NOTE(yikun): This should be removed in Stein version.
223+
+ if not _should_enable_custom_max_server_rules(context, rules):
224+
+ msg = _("Creating an anti-affinity group with rule "
225+
+ "max_server_per_host > 1 is not yet supported.")
226+
+ raise exc.HTTPConflict(explanation=msg)
227+
+ elif policy in ('network-group-affinity',
228+
+ 'network-group-anti-affinity'):
229+
+ if 'max_server_per_host' in rules:
230+
+ msg = _("network-group-affinity and "
231+
+ "network-group-anti-affinity policies do not "
232+
+ "support the max_server_per_host rule.")
233+
+ raise exc.HTTPBadRequest(explanation=msg)
234+
+ if 'network_group' not in rules:
235+
+ msg = _("network-group-affinity and "
236+
+ "network-group-anti-affinity policies require "
237+
+ "a network_group rule.")
238+
+ raise exc.HTTPBadRequest(explanation=msg)
239+
+ elif rules:
240+
+ msg = _("Only anti-affinity, network-group-affinity, and "
241+
+ "network-group-anti-affinity policies support rules.")
242+
+ raise exc.HTTPBadRequest(explanation=msg)
243+
sg = objects.InstanceGroup(context, policy=policy,
244+
rules=rules)
245+
diff --git a/nova/objects/instance_group.py b/nova/objects/instance_group.py
246+
index abcdef9012..9012abcdef 100644
247+
--- a/nova/objects/instance_group.py
248+
+++ b/nova/objects/instance_group.py
249+
@@ -151,6 +151,8 @@ class InstanceGroup(base.NovaPersistentObject, base.NovaObject,
250+
if 'max_server_per_host' in self._rules:
251+
rules['max_server_per_host'] = \
252+
int(self._rules['max_server_per_host'])
253+
+ if 'network_group' in self._rules:
254+
+ rules['network_group'] = self._rules['network_group']
255+
return rules
256+
257+
def obj_make_compatible(self, primitive, target_version):
258+
diff --git a/nova/scheduler/utils.py b/nova/scheduler/utils.py
259+
index abcdef3456..3456abcdef 100644
260+
--- a/nova/scheduler/utils.py
261+
+++ b/nova/scheduler/utils.py
262+
@@ -1212,7 +1212,8 @@ def setup_instance_group(context, request_spec):
263+
264+
- policies = set(('anti-affinity', 'affinity', 'soft-affinity',
265+
- 'soft-anti-affinity'))
266+
+ policies = set(('anti-affinity', 'affinity', 'soft-affinity',
267+
+ 'soft-anti-affinity', 'network-group-affinity',
268+
+ 'network-group-anti-affinity'))
269+
if group.policy in policies:
270+
diff --git a/nova/conf/scheduler.py b/nova/conf/scheduler.py
271+
index abcdef7890..7890abcdef 100644
272+
--- a/nova/conf/scheduler.py
273+
+++ b/nova/conf/scheduler.py
274+
@@ -328,6 +328,8 @@ All filters in the list are optional.
275+
"ImagePropertiesFilter",
276+
"ServerGroupAntiAffinityFilter",
277+
"ServerGroupAffinityFilter",
278+
+ "NetworkGroupAffinityFilter",
279+
+ "NetworkGroupAntiAffinityFilter",
280+
],
281+
help="""

containers/nova/patches/series

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
0001_trunk_details_metadata.patch
22
ironic-attach-debug.patch
3+
0002_network_group_affinity_policy.patch

python/ironic-understack/ironic_understack/inspect_hook_update_baremetal_ports.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,16 +236,28 @@ def _set_node_traits(task, vlan_groups: set[str]):
236236
For example, a connection to VLAN Group whose name ends in "-storage" will
237237
result in a trait being added to the node called "CUSTOM_STORAGE_SWITCH".
238238
239+
We also add a CUSTOM_NETGROUP_<name> trait for each "-network" VLAN group
240+
the node is connected to. This trait is used by the Nova scheduler's
241+
NetworkGroupAffinityFilter and NetworkGroupAntiAffinityFilter to constrain
242+
instance placement to specific cabinet switch pairs.
243+
239244
We remove pre-existing traits if the node does not have the required
240245
connections.
241246
242-
Traits other than CUSTOM_*_SWITCH are left alone.
247+
Traits other than CUSTOM_*_SWITCH and CUSTOM_NETGROUP_* are left alone.
243248
"""
244249
node = task.node
245250
existing_traits = set(node.traits.get_trait_names())
246251
vlan_group_traits = {_trait_name(x) for x in vlan_groups if x}
252+
network_group_traits = {
253+
_network_group_trait_name(x)
254+
for x in vlan_groups
255+
if x and x.endswith("-network")
256+
}
247257
irrelevant_existing_traits = {x for x in existing_traits if not _is_our_trait(x)}
248-
required_traits = irrelevant_existing_traits.union(vlan_group_traits)
258+
required_traits = irrelevant_existing_traits.union(
259+
vlan_group_traits
260+
).union(network_group_traits)
249261

250262
if existing_traits == required_traits:
251263
LOG.debug(
@@ -269,5 +281,22 @@ def _trait_name(vlan_group_name: str) -> str:
269281
return f"CUSTOM_{suffix}_SWITCH"
270282

271283

284+
def _network_group_trait_name(vlan_group_name: str) -> str:
285+
"""Convert a VLAN group name to a CUSTOM_NETGROUP_* trait.
286+
287+
This trait is consumed by Nova's NetworkGroupAffinityFilter and
288+
NetworkGroupAntiAffinityFilter to constrain scheduling to nodes
289+
within a specific cabinet / switch pair.
290+
291+
Example: "a1-1-network" -> "CUSTOM_NETGROUP_A1_1_NETWORK"
292+
Example: "a11-12/a11-13-network" -> "CUSTOM_NETGROUP_A11_12_A11_13_NETWORK"
293+
"""
294+
normalised = vlan_group_name.upper().replace("-", "_").replace("/", "_")
295+
return f"CUSTOM_NETGROUP_{normalised}"
296+
297+
272298
def _is_our_trait(name: str) -> bool:
273-
return bool(re.match(r"^CUSTOM_[A-Z0-9]+_SWITCH$", name))
299+
return bool(
300+
re.match(r"^CUSTOM_[A-Z0-9]+_SWITCH$", name)
301+
or re.match(r"^CUSTOM_NETGROUP_[A-Z0-9_]+$", name)
302+
)

0 commit comments

Comments
 (0)