|
| 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") |
0 commit comments