f45cad94a2
Change-Id: If93ba5f05103498972c8e8b3e8c2301a543f1c8d
268 lines
8.8 KiB
Python
268 lines
8.8 KiB
Python
# 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 scheduling nodes across availability zones.
|
|
|
|
NOTE: For full documentation about how the policy works, check:
|
|
https://docs.openstack.org/senlin/latest/contributor/policies/zone_v1.html
|
|
"""
|
|
|
|
import math
|
|
import operator
|
|
|
|
from oslo_log import log as logging
|
|
|
|
from senlin.common import consts
|
|
from senlin.common import exception as exc
|
|
from senlin.common.i18n import _
|
|
from senlin.common import scaleutils
|
|
from senlin.common import schema
|
|
from senlin.engine import cluster as cm
|
|
from senlin.objects import cluster as co
|
|
from senlin.objects import node as no
|
|
from senlin.policies import base
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
|
|
class ZonePlacementPolicy(base.Policy):
|
|
"""Policy for placing members of a cluster across availability zones."""
|
|
|
|
VERSION = '1.0'
|
|
VERSIONS = {
|
|
'1.0': [
|
|
{'status': consts.EXPERIMENTAL, 'since': '2016.04'},
|
|
{'status': consts.SUPPORTED, 'since': '2016.10'},
|
|
]
|
|
}
|
|
PRIORITY = 300
|
|
|
|
TARGET = [
|
|
('BEFORE', consts.CLUSTER_SCALE_OUT),
|
|
('BEFORE', consts.CLUSTER_SCALE_IN),
|
|
('BEFORE', consts.CLUSTER_RESIZE),
|
|
('BEFORE', consts.NODE_CREATE),
|
|
]
|
|
|
|
PROFILE_TYPE = [
|
|
'os.nova.server-1.0',
|
|
]
|
|
|
|
KEYS = (
|
|
ZONES,
|
|
) = (
|
|
'zones',
|
|
)
|
|
|
|
_AZ_KEYS = (
|
|
ZONE_NAME, ZONE_WEIGHT,
|
|
) = (
|
|
'name', 'weight',
|
|
)
|
|
|
|
properties_schema = {
|
|
ZONES: schema.List(
|
|
_('List of availability zones to choose from.'),
|
|
schema=schema.Map(
|
|
_('An availability zone as candidate.'),
|
|
schema={
|
|
ZONE_NAME: schema.String(
|
|
_('Name of an availability zone.'),
|
|
),
|
|
ZONE_WEIGHT: schema.Integer(
|
|
_('Weight of the availability zone (default is 100).'),
|
|
default=100,
|
|
required=False,
|
|
)
|
|
},
|
|
),
|
|
),
|
|
}
|
|
|
|
def __init__(self, name, spec, **kwargs):
|
|
super(ZonePlacementPolicy, self).__init__(name, spec, **kwargs)
|
|
|
|
self.zones = dict((z[self.ZONE_NAME], z[self.ZONE_WEIGHT])
|
|
for z in self.properties.get(self.ZONES))
|
|
|
|
def validate(self, context, validate_props=False):
|
|
super(ZonePlacementPolicy, self).validate(context, validate_props)
|
|
|
|
if not validate_props:
|
|
return True
|
|
|
|
nc = self.nova(context.user_id, context.project_id)
|
|
input_azs = sorted(self.zones.keys())
|
|
valid_azs = nc.validate_azs(input_azs)
|
|
invalid_azs = sorted(set(input_azs) - set(valid_azs))
|
|
if invalid_azs:
|
|
msg = _("The specified %(key)s '%(value)s' could not be "
|
|
"found.") % {'key': self.ZONE_NAME,
|
|
'value': list(invalid_azs)}
|
|
raise exc.InvalidSpec(message=msg)
|
|
|
|
return True
|
|
|
|
def _create_plan(self, current, zones, count, expand):
|
|
"""Compute a placement plan based on the weights of AZs.
|
|
|
|
:param current: Distribution of existing nodes.
|
|
:returns: A dict that contains a placement plan.
|
|
"""
|
|
# sort candidate zones by distribution and covert it into a list
|
|
candidates = sorted(zones.items(), key=operator.itemgetter(1),
|
|
reverse=expand)
|
|
|
|
sum_weight = sum(zones.values())
|
|
if expand:
|
|
total = count + sum(current.values())
|
|
else:
|
|
total = sum(current.values()) - count
|
|
|
|
remain = count
|
|
plan = dict.fromkeys(zones.keys(), 0)
|
|
|
|
for i in range(len(zones)):
|
|
zone = candidates[i][0]
|
|
weight = candidates[i][1]
|
|
q = total * weight / float(sum_weight)
|
|
if expand:
|
|
quota = int(math.ceil(q))
|
|
headroom = quota - current[zone]
|
|
else:
|
|
quota = int(math.floor(q))
|
|
headroom = current[zone] - quota
|
|
|
|
if headroom <= 0:
|
|
continue
|
|
|
|
if headroom < remain:
|
|
plan[zone] = headroom
|
|
remain -= headroom
|
|
else:
|
|
plan[zone] = remain if remain > 0 else 0
|
|
remain = 0
|
|
break
|
|
|
|
if remain > 0:
|
|
return None
|
|
|
|
# filter out zero values
|
|
result = {}
|
|
for z, c in plan.items():
|
|
if c > 0:
|
|
result[z] = c
|
|
|
|
return result
|
|
|
|
def _get_count(self, cluster_id, action):
|
|
"""Get number of nodes to create or delete.
|
|
|
|
:param cluster_id: The ID of the target cluster.
|
|
:param action: The action object which triggered this policy check.
|
|
:return: An integer value which can be 1) positive - number of nodes
|
|
to create; 2) negative - number of nodes to delete; 3) 0 -
|
|
something wrong happened, and the policy check failed.
|
|
"""
|
|
if action.action == consts.NODE_CREATE:
|
|
# skip the policy if availability zone is specified in profile
|
|
profile = action.entity.rt['profile']
|
|
if profile.properties[profile.AVAILABILITY_ZONE]:
|
|
return 0
|
|
return 1
|
|
|
|
if action.action == consts.CLUSTER_RESIZE:
|
|
if action.data.get('deletion', None):
|
|
return -action.data['deletion']['count']
|
|
elif action.data.get('creation', None):
|
|
return action.data['creation']['count']
|
|
|
|
db_cluster = co.Cluster.get(action.context, cluster_id)
|
|
current = no.Node.count_by_cluster(action.context, cluster_id)
|
|
res = scaleutils.parse_resize_params(action, db_cluster, current)
|
|
if res[0] == base.CHECK_ERROR:
|
|
action.data['status'] = base.CHECK_ERROR
|
|
action.data['reason'] = res[1]
|
|
LOG.error(res[1])
|
|
return 0
|
|
|
|
if action.data.get('deletion', None):
|
|
return -action.data['deletion']['count']
|
|
else:
|
|
return action.data['creation']['count']
|
|
|
|
if action.action == consts.CLUSTER_SCALE_IN:
|
|
pd = action.data.get('deletion', None)
|
|
if pd is None:
|
|
return -action.inputs.get('count', 1)
|
|
else:
|
|
return -pd.get('count', 1)
|
|
|
|
# CLUSTER_SCALE_OUT: an action that inflates the cluster
|
|
pd = action.data.get('creation', None)
|
|
if pd is None:
|
|
return action.inputs.get('count', 1)
|
|
else:
|
|
return pd.get('count', 1)
|
|
|
|
def pre_op(self, cluster_id, action):
|
|
"""Callback function when cluster membership is about to change.
|
|
|
|
:param cluster_id: ID of the target cluster.
|
|
:param action: The action that triggers this policy check.
|
|
"""
|
|
count = self._get_count(cluster_id, action)
|
|
if count == 0:
|
|
return
|
|
|
|
expand = True
|
|
if count < 0:
|
|
expand = False
|
|
count = -count
|
|
|
|
cluster = cm.Cluster.load(action.context, cluster_id)
|
|
|
|
nc = self.nova(cluster.user, cluster.project)
|
|
zones_good = nc.validate_azs(self.zones.keys())
|
|
if len(zones_good) == 0:
|
|
action.data['status'] = base.CHECK_ERROR
|
|
action.data['reason'] = _('No availability zone found available.')
|
|
LOG.error('No availability zone found available.')
|
|
return
|
|
|
|
zones = {}
|
|
for z, w in self.zones.items():
|
|
if z in zones_good:
|
|
zones[z] = w
|
|
|
|
current = cluster.get_zone_distribution(action.context, zones.keys())
|
|
result = self._create_plan(current, zones, count, expand)
|
|
|
|
if not result:
|
|
action.data['status'] = base.CHECK_ERROR
|
|
action.data['reason'] = _('There is no feasible plan to '
|
|
'handle all nodes.')
|
|
LOG.error('There is no feasible plan to handle all nodes.')
|
|
return
|
|
|
|
if expand:
|
|
if 'creation' not in action.data:
|
|
action.data['creation'] = {}
|
|
action.data['creation']['count'] = count
|
|
action.data['creation']['zones'] = result
|
|
else:
|
|
if 'deletion' not in action.data:
|
|
action.data['deletion'] = {}
|
|
action.data['deletion']['count'] = count
|
|
action.data['deletion']['zones'] = result
|