50cf161bf9
node_name_prefix (default tk0) can be used to set a custom prefix for the node names. vol_name_prefix (default vol) can be used to set a custom prefix for the volume names. Support already existed for these in the action plugin, but the variables were not passed through. Change-Id: If968707285f9a8450bf16ca7a4956de6164986de TrivialFix
331 lines
13 KiB
Python
331 lines
13 KiB
Python
# Copyright (c) 2018 StackHPC Ltd.
|
|
#
|
|
# 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.
|
|
|
|
# Avoid shadowing of system copy module by copy action plugin.
|
|
from __future__ import absolute_import
|
|
import abc
|
|
from copy import deepcopy
|
|
import itertools
|
|
import re
|
|
|
|
from ansible.errors import AnsibleActionFail
|
|
from ansible.module_utils._text import to_text
|
|
from ansible.plugins.action import ActionBase
|
|
import six
|
|
|
|
|
|
class ActionModule(ActionBase):
|
|
def run(self, tmp=None, task_vars=None):
|
|
"""
|
|
Produce a dict of Tenks state.
|
|
|
|
Actions include:
|
|
* Generating indices for physical networks for each hypervisor.
|
|
* Scheduling specifications of nodes by type onto hypervisors.
|
|
|
|
The following task arguments are accepted:
|
|
:hypervisor_vars: A dict of hostvars for each hypervisor, keyed
|
|
by hypervisor hostname. Required.
|
|
:specs: A list of node specifications to be instantiated. Required.
|
|
:node_types: A dict mapping node type names to a dict of properties
|
|
of that type.
|
|
:node_name_prefix: A string with which to prefix all sequential
|
|
node names.
|
|
:vol_name_prefix: A string with which to prefix all sequential
|
|
volume names.
|
|
:state: A dict of existing Tenks state (as produced by a previous
|
|
run of this module), to be taken into account in this run.
|
|
Optional.
|
|
:prune_only: A boolean which, if set, will instruct the plugin to
|
|
only remove any nodes with state='absent' from
|
|
`state`.
|
|
:returns: A dict of Tenks state for each hypervisor, keyed by the
|
|
hostname of the hypervisor to which the state refers.
|
|
"""
|
|
result = super(ActionModule, self).run(tmp, task_vars)
|
|
# Initialise our return dict.
|
|
result['result'] = {}
|
|
del tmp # tmp no longer has any effect
|
|
|
|
self.args = self._task.args
|
|
self.localhost_vars = task_vars['hostvars']['localhost']
|
|
self._validate_args()
|
|
|
|
if self.args['prune_only']:
|
|
self._prune_absent_nodes()
|
|
else:
|
|
# Modify the state as necessary.
|
|
self._set_physnet_idxs()
|
|
self._process_specs()
|
|
|
|
# Return the modified state.
|
|
result['result'] = self.args['state']
|
|
return result
|
|
|
|
def _prune_absent_nodes(self):
|
|
"""
|
|
Remove any nodes with state='absent' from the state dict.
|
|
"""
|
|
for hyp in six.itervalues(self.args['state']):
|
|
hyp['nodes'] = [n for n in hyp['nodes']
|
|
if n.get('state') != 'absent']
|
|
|
|
def _set_physnet_idxs(self):
|
|
"""
|
|
Set the index of each physnet for each host.
|
|
|
|
Use the specified physnet mappings and any existing physnet indices to
|
|
ensure the generated indices are consistent.
|
|
"""
|
|
state = self.args['state']
|
|
for hostname, hostvars in six.iteritems(self.args['hypervisor_vars']):
|
|
# The desired mappings given in the Tenks configuration. These do
|
|
# not include IDXs which are an implementation detail of Tenks.
|
|
specified_mappings = hostvars['physnet_mappings']
|
|
try:
|
|
# The physnet indices currently in the state file.
|
|
old_idxs = state[hostname]['physnet_indices']
|
|
except KeyError:
|
|
# The hypervisor is new since the last run.
|
|
state[hostname] = {}
|
|
old_idxs = {}
|
|
new_idxs = {}
|
|
next_idx = 0
|
|
used_idxs = list(six.itervalues(old_idxs))
|
|
for name, dev in six.iteritems(specified_mappings):
|
|
try:
|
|
# We need to re-use the IDXs of any existing physnets.
|
|
idx = old_idxs[name]
|
|
except KeyError:
|
|
# New physnet requires a new IDX.
|
|
while next_idx in used_idxs:
|
|
next_idx += 1
|
|
used_idxs.append(next_idx)
|
|
idx = next_idx
|
|
new_idxs[name] = idx
|
|
state[hostname]['physnet_indices'] = new_idxs
|
|
|
|
def _process_specs(self):
|
|
"""
|
|
Ensure the correct nodes are present in `state`.
|
|
|
|
Remove unnecessary nodes by marking as 'absent' and schedule new nodes
|
|
to hypervisors such that the nodes in `state` match what's specified in
|
|
`specs`.
|
|
"""
|
|
# Iterate through existing nodes, marking for deletion where necessary.
|
|
for hyp in six.itervalues(self.args['state']):
|
|
# Absent nodes cannot fulfil a spec.
|
|
for node in [n for n in hyp.get('nodes', [])
|
|
if n.get('state') != 'absent']:
|
|
if ((self.localhost_vars['cmd'] == 'teardown' or
|
|
not self._tick_off_node(self.args['specs'], node))):
|
|
# We need to delete this node, since it exists but does not
|
|
# fulfil any spec.
|
|
node['state'] = 'absent'
|
|
|
|
if self.localhost_vars['cmd'] != 'teardown':
|
|
# Ensure all hosts exist in state.
|
|
for hostname in self.args['hypervisor_vars']:
|
|
self.args['state'].setdefault(hostname, {})
|
|
self.args['state'][hostname].setdefault('nodes', [])
|
|
# Now create all the required new nodes.
|
|
scheduler = RoundRobinScheduler(self.args['hypervisor_vars'],
|
|
self.args['state'])
|
|
self._create_nodes(scheduler)
|
|
|
|
def _tick_off_node(self, specs, node):
|
|
"""
|
|
Tick off an existing node as fulfilling a node specification.
|
|
|
|
If `node` is required in `specs`, decrement that spec's count and
|
|
return True. Otherwise, return False.
|
|
"""
|
|
# Attributes that a spec and a node have to have in common for the node
|
|
# to count as an 'instance' of the spec.
|
|
MATCHING_ATTRS = {'type', 'ironic_config'}
|
|
for spec in specs:
|
|
if (all(spec.get(attr) == node.get(attr)
|
|
for attr in MATCHING_ATTRS)
|
|
and spec['count'] > 0):
|
|
spec['count'] -= 1
|
|
return True
|
|
return False
|
|
|
|
def _create_nodes(self, scheduler):
|
|
"""
|
|
Create new nodes to fulfil the specs.
|
|
"""
|
|
# Anything left in specs needs to be created.
|
|
for spec in self.args['specs']:
|
|
for _ in six.moves.range(spec['count']):
|
|
node = self._gen_node(spec['type'], spec.get('ironic_config'))
|
|
hostname, idx = scheduler.choose_host(node)
|
|
# Set node name based on its index.
|
|
node['name'] = "%s%d" % (self.args['node_name_prefix'], idx)
|
|
# Sequentially number the volume names.
|
|
for vol_idx, vol in enumerate(node['volumes']):
|
|
vol['name'] = ("%s%s%d"
|
|
% (node['name'],
|
|
self.args['vol_name_prefix'], vol_idx))
|
|
# Set IPMI port using its index as an offset from the lowest
|
|
# port.
|
|
node['ipmi_port'] = (
|
|
self.args['hypervisor_vars'][hostname][
|
|
'ipmi_port_range_start'] + idx)
|
|
self.args['state'][hostname]['nodes'].append(node)
|
|
|
|
def _gen_node(self, type_name, ironic_config=None):
|
|
"""
|
|
Generate a node description.
|
|
|
|
A name will not be assigned at this point because we don't know which
|
|
hypervisor the node will be scheduled to.
|
|
"""
|
|
node_type = self.args['node_types'][type_name]
|
|
node = deepcopy(node_type)
|
|
# All nodes need an Ironic driver.
|
|
node.setdefault(
|
|
'ironic_driver',
|
|
self.localhost_vars['default_ironic_driver']
|
|
)
|
|
# Set the type name, for future reference.
|
|
node['type'] = type_name
|
|
# Ironic config is not mandatory.
|
|
if ironic_config:
|
|
node['ironic_config'] = ironic_config
|
|
return node
|
|
|
|
def _validate_args(self):
|
|
if self.args is None:
|
|
self.args = {}
|
|
|
|
REQUIRED_ARGS = {'hypervisor_vars', 'specs', 'node_types'}
|
|
# Var names and their defaults.
|
|
OPTIONAL_ARGS = [
|
|
('node_name_prefix', 'tk'),
|
|
# state is optional, since if this is the first run there won't be
|
|
# any yet.
|
|
('state', {}),
|
|
('vol_name_prefix', 'vol'),
|
|
('prune_only', False),
|
|
]
|
|
for arg in OPTIONAL_ARGS:
|
|
if arg[0] not in self.args:
|
|
self.args[arg[0]] = arg[1]
|
|
|
|
# No arguments are required in prune_only mode.
|
|
if not self.args['prune_only']:
|
|
for arg in REQUIRED_ARGS:
|
|
if arg not in self.args:
|
|
e = "The parameter '%s' must be specified." % arg
|
|
raise AnsibleActionFail(to_text(e))
|
|
|
|
if not self.args['hypervisor_vars']:
|
|
e = ("There are no hosts in the 'hypervisors' group to which "
|
|
"we can schedule.")
|
|
raise AnsibleActionFail(to_text(e))
|
|
|
|
for spec in self.args['specs']:
|
|
if 'type' not in spec or 'count' not in spec:
|
|
e = ("All specs must contain a `type` and a `count`. "
|
|
"Offending spec: %s" % spec)
|
|
raise AnsibleActionFail(to_text(e))
|
|
|
|
|
|
@six.add_metaclass(abc.ABCMeta)
|
|
class Scheduler():
|
|
"""
|
|
Abstract class representing a 'method' of scheduling nodes to hosts.
|
|
"""
|
|
def __init__(self, hostvars, state):
|
|
self.hostvars = hostvars
|
|
self.state = state
|
|
|
|
self._host_free_idxs = {}
|
|
|
|
@abc.abstractmethod
|
|
def choose_host(self, node):
|
|
"""Abstract method to choose a host to which we can schedule `node`.
|
|
|
|
Returns a tuple of the hostname of the chosen host and the index of
|
|
this node on the host.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def host_next_idx(self, hostname):
|
|
"""
|
|
Return the next available index for a node on this host.
|
|
|
|
If the free indices are not cached for this host, they will be
|
|
calculated.
|
|
|
|
:param hostname: The name of the host in question
|
|
:returns: The next available index, or None if none is available
|
|
"""
|
|
if hostname not in self._host_free_idxs:
|
|
self._calculate_free_idxs(hostname)
|
|
try:
|
|
return self._host_free_idxs[hostname].pop(0)
|
|
except IndexError:
|
|
return None
|
|
|
|
def host_passes(self, node, hostname):
|
|
"""
|
|
Perform checks to ascertain whether this host can support this node.
|
|
"""
|
|
# Check that the host is connected to all physical networks that the
|
|
# node requires.
|
|
return all(pn in self.hostvars[hostname]['physnet_mappings'].keys()
|
|
for pn in node['physical_networks'])
|
|
|
|
def _calculate_free_idxs(self, hostname):
|
|
# The maximum number of nodes this host can have is the number of
|
|
# IPMI ports it has available.
|
|
all_idxs = six.moves.range(
|
|
self.hostvars[hostname]['ipmi_port_range_end'] -
|
|
self.hostvars[hostname]['ipmi_port_range_start'] + 1)
|
|
get_idx = (
|
|
lambda n: int(re.match(r'[A-Za-z]*([0-9]+)$', n).group(1)))
|
|
used_idxs = {get_idx(n['name']) for n in self.state[hostname]['nodes']
|
|
if n.get('state') != 'absent'}
|
|
self._host_free_idxs[hostname] = sorted([i for i in all_idxs
|
|
if i not in used_idxs])
|
|
|
|
|
|
class RoundRobinScheduler(Scheduler):
|
|
"""
|
|
Schedule nodes in a round-robin fashion to hosts.
|
|
"""
|
|
def __init__(self, hostvars, state):
|
|
super(RoundRobinScheduler, self).__init__(hostvars, state)
|
|
self.hostvars = hostvars
|
|
self._host_cycle = itertools.cycle(hostvars.keys())
|
|
|
|
def choose_host(self, node):
|
|
idx = None
|
|
count = 0
|
|
while idx is None:
|
|
# Ensure we don't get into an infinite loop if no hosts are
|
|
# available.
|
|
if count >= len(self.hostvars):
|
|
e = ("No hypervisors are left that can support the node %s."
|
|
% node)
|
|
raise AnsibleActionFail(to_text(e))
|
|
count += 1
|
|
hostname = next(self._host_cycle)
|
|
if self.host_passes(node, hostname):
|
|
idx = self.host_next_idx(hostname)
|
|
return hostname, idx
|