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