# Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. """ Policy for placing nodes based on Nova server groups. NOTE: For full documentation about how the affinity policy works, check: https://docs.openstack.org/senlin/latest/contributor/policies/affinity_v1.html """ import re from oslo_log import log as logging from senlin.common import constraints from senlin.common import consts from senlin.common import context from senlin.common import exception as exc from senlin.common.i18n import _ from senlin.common import scaleutils as su from senlin.common import schema from senlin.common import utils from senlin.objects import cluster_policy as cpo from senlin.policies import base LOG = logging.getLogger(__name__) class AffinityPolicy(base.Policy): """Policy for placing members of a cluster based on server groups. This policy is expected to be enforced before new member(s) added to an existing cluster. """ VERSION = '1.0' VERSIONS = { '1.0': [ {'status': consts.SUPPORTED, 'since': '2016.10'} ] } PRIORITY = 300 TARGET = [ ('BEFORE', consts.CLUSTER_SCALE_OUT), ('BEFORE', consts.CLUSTER_RESIZE), ('BEFORE', consts.NODE_CREATE), ] PROFILE_TYPE = [ 'os.nova.server-1.0', ] KEYS = ( SERVER_GROUP, AVAILABILITY_ZONE, ENABLE_DRS_EXTENSION, ) = ( 'servergroup', 'availability_zone', 'enable_drs_extension', ) _GROUP_KEYS = ( GROUP_NAME, GROUP_POLICIES, ) = ( 'name', 'policies', ) _POLICIES_VALUES = ( # NOTE: soft policies are supported from compute micro version 2.15 AFFINITY, SOFT_AFFINITY, ANTI_AFFINITY, SOFT_ANTI_AFFINITY, ) = ( 'affinity', 'soft-affinity', 'anti-affinity', 'soft-anti-affinity', ) properties_schema = { SERVER_GROUP: schema.Map( _('Properties of the VM server group'), schema={ GROUP_NAME: schema.String( _('The name of the server group'), ), GROUP_POLICIES: schema.String( _('The server group policies.'), default=ANTI_AFFINITY, constraints=[ constraints.AllowedValues(_POLICIES_VALUES), ], ), }, ), AVAILABILITY_ZONE: schema.String( _('Name of the availability zone to place the nodes.'), ), ENABLE_DRS_EXTENSION: schema.Boolean( _('Enable vSphere DRS extension.'), default=False, ), } def __init__(self, name, spec, **kwargs): super(AffinityPolicy, self).__init__(name, spec, **kwargs) self.enable_drs = self.properties.get(self.ENABLE_DRS_EXTENSION) def validate(self, context, validate_props=False): super(AffinityPolicy, self).validate(context, validate_props) if not validate_props: return True az_name = self.properties.get(self.AVAILABILITY_ZONE) if az_name: nc = self.nova(context.user_id, context.project_id) valid_azs = nc.validate_azs([az_name]) if not valid_azs: msg = _("The specified %(key)s '%(value)s' could not be " "found.") % {'key': self.AVAILABILITY_ZONE, 'value': az_name} raise exc.InvalidSpec(message=msg) return True def attach(self, cluster, enabled=True): """Routine to be invoked when policy is to be attached to a cluster. :para cluster: The cluster to which the policy is being attached to. :param enabled: The attached cluster policy is enabled or disabled. :returns: When the operation was successful, returns a tuple (True, message); otherwise, return a tuple (False, error). """ res, data = super(AffinityPolicy, self).attach(cluster) if res is False: return False, data data = {'inherited_group': False} nc = self.nova(cluster.user, cluster.project) group = self.properties.get(self.SERVER_GROUP) # guess servergroup name group_name = group.get(self.GROUP_NAME, None) if group_name is None: profile = cluster.rt['profile'] if 'scheduler_hints' in profile.spec: hints = profile.spec['scheduler_hints'] group_name = hints.get('group', None) if group_name: try: server_group = nc.server_group_find(group_name, True) except exc.InternalError as ex: msg = _("Failed in retrieving servergroup '%s'." ) % group_name LOG.exception('%(msg)s: %(ex)s', {'msg': msg, 'ex': ex}) return False, msg if server_group: # Check if the policies match policies = group.get(self.GROUP_POLICIES) if policies and policies != server_group.policies[0]: msg = _("Policies specified (%(specified)s) doesn't match " "that of the existing servergroup (%(existing)s)." ) % {'specified': policies, 'existing': server_group.policies[0]} return False, msg data['servergroup_id'] = server_group.id data['inherited_group'] = True if not data['inherited_group']: # create a random name if necessary if not group_name: group_name = 'server_group_%s' % utils.random_name() try: server_group = nc.server_group_create( name=group_name, policies=[group.get(self.GROUP_POLICIES)]) except Exception as ex: msg = _('Failed in creating servergroup.') LOG.exception('%(msg)s: %(ex)s', {'msg': msg, 'ex': ex}) return False, msg data['servergroup_id'] = server_group.id policy_data = self._build_policy_data(data) return True, policy_data def detach(self, cluster): """Routine to be called when the policy is detached from a cluster. :param cluster: The cluster from which the policy is to be detached. :returns: When the operation was successful, returns a tuple of (True, data) where the data contains references to the resources created; otherwise returns a tuple of (False, error) where the err contains an error message. """ reason = _('Servergroup resource deletion succeeded.') ctx = context.get_admin_context() binding = cpo.ClusterPolicy.get(ctx, cluster.id, self.id) if not binding or not binding.data: return True, reason policy_data = self._extract_policy_data(binding.data) if not policy_data: return True, reason group_id = policy_data.get('servergroup_id', None) inherited_group = policy_data.get('inherited_group', False) if group_id and not inherited_group: try: nc = self.nova(cluster.user, cluster.project) nc.server_group_delete(group_id) except Exception as ex: msg = _('Failed in deleting servergroup.') LOG.exception('%(msg)s: %(ex)s', {'msg': msg, 'ex': ex}) return False, msg return True, reason def pre_op(self, cluster_id, action): """Routine to be called before target action is executed. This policy annotates the node with a server group ID before the node is actually created. For vSphere DRS, it is equivalent to the selection of vSphere host (cluster). :param cluster_id: ID of the cluster on which the relevant action is to be executed. :param action: The action object that triggered this operation. :returns: Nothing. """ zone_name = self.properties.get(self.AVAILABILITY_ZONE) if not zone_name and self.enable_drs: # we make a reasonable guess of the zone name for vSphere # support because the zone name is required in that case. zone_name = 'nova' # we respect other policies decisions (if any) and fall back to the # action inputs if no hints found. pd = action.data.get('creation', None) if pd is not None: count = pd.get('count', 1) elif action.action == consts.CLUSTER_SCALE_OUT: count = action.inputs.get('count', 1) elif action.action == consts.NODE_CREATE: count = 1 else: # CLUSTER_RESIZE cluster = action.entity current = len(cluster.nodes) su.parse_resize_params(action, cluster, current) if 'creation' not in action.data: return count = action.data['creation']['count'] cp = cpo.ClusterPolicy.get(action.context, cluster_id, self.id) policy_data = self._extract_policy_data(cp.data) pd_entry = {'servergroup': policy_data['servergroup_id']} # special handling for vSphere DRS case where we need to find out # the name of the vSphere host which has DRS enabled. if self.enable_drs: obj = action.entity nc = self.nova(obj.user, obj.project) hypervisors = nc.hypervisor_list() hv_id = '' pattern = re.compile(r'.*drs*', re.I) for hypervisor in hypervisors: match = pattern.match(hypervisor.hypervisor_hostname) if match: hv_id = hypervisor.id break if not hv_id: action.data['status'] = base.CHECK_ERROR action.data['status_reason'] = _('No suitable vSphere host ' 'is available.') action.store(action.context) return hv_info = nc.hypervisor_get(hv_id) hostname = hv_info['service']['host'] pd_entry['zone'] = ":".join([zone_name, hostname]) elif zone_name: pd_entry['zone'] = zone_name pd = { 'count': count, 'placements': [pd_entry] * count, } action.data.update({'placement': pd}) action.store(action.context) return