Skip to content

Commit 0bd9a3d

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 0bd9a3d

4 files changed

Lines changed: 405 additions & 4 deletions

File tree

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

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(vlan_group_traits).union(
259+
network_group_traits
260+
)
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)