diff --git a/contrib/templates/example/README.rst b/contrib/templates/example/README.rst new file mode 100644 index 0000000000..3271f2fb1c --- /dev/null +++ b/contrib/templates/example/README.rst @@ -0,0 +1,105 @@ +==================== +Example Bay Template +==================== + +This project is an example to demonstrate the necessary pieces of a Bay +template. There are three key pieces to a bay template: + +1. Heat template - The Heat template that Magnum will use to generate a Bay. +2. Template definition - Magnum's interface for interacting with the Heat template. +3. Definition Entry Point - Used to advertise the available template definitions. + +The Heat Template +----------------- + +The heat template is where most of the real work happens. The result of the Heat +template should be a full Container Orchestration Environment. + +The Template Definition +----------------------- + +Template definitions are a mapping of Magnum object attributes and Heat template +parameters, along with Magnum consumable template outputs. Each definition also +denotes which Bay Types it can provide. Bay Types are how Magnum determines which +of the enabled Template Definitions it will use for a given Bay. + +The Definition Entry Point +-------------------------- + +Entry points are a standard discovery and import mechanism for Python objects. +Each Template Definition should have an Entry Point in the `magnum.template_definitions` +group. This example exposes it's Template Definition as `example_template = example_template:ExampleTemplate` +in the `magnum.template_definitions` group. + +Installing Bay Templates +------------------------ + +Because Bay Templates are basically Python projects, they can be worked with like +any other Python project. They can be cloned from version control and installed +or uploaded to a package index and installed via utilities such as pip. + +Enabling a template is as simple as adding it's Entry Point to the +`enabled_definitions` config option in magnum.conf.:: + + # Setup python environment and install Magnum + + $ virtualenv .venv + $ source .venv/bin/active + (.venv)$ git clone https://github.com/openstack/magnum.git + (.venv)$ cd magnum + (.venv)$ python setup.py install + + # List installed templates, notice default templates are enabled + + (.venv)$ magnum-template-manage list-templates + Enabled Templates + magnum_vm_atomic_k8s: /home/example/.venv/local/lib/python2.7/site-packages/magnum/templates/heat-kubernetes/kubecluster.yaml + magnum_vm_coreos_k8s: /home/example/.venv/local/lib/python2.7/site-packages/magnum/templates/heat-kubernetes/kubecluster-coreos.yaml + Disabled Templates + + # Install example template + + (.venv)$ cd contrib/templates/example + (.venv)$ python setup.py install + + # List installed templates, notice example template is disabled + + (.venv)$ magnum-template-manage list-templates + Enabled Templates + magnum_vm_atomic_k8s: /home/example/.venv/local/lib/python2.7/site-packages/magnum/templates/heat-kubernetes/kubecluster.yaml + magnum_vm_coreos_k8s: /home/example/.venv/local/lib/python2.7/site-packages/magnum/templates/heat-kubernetes/kubecluster-coreos.yaml + Disabled Templates + example_template: /home/example/.venv/local/lib/python2.7/site-packages/ExampleTemplate-0.1-py2.7.egg/example_template/example.yaml + + # Enable example template by setting enabled_definitions in magnum.conf + + (.venv)$ sudo mkdir /etc/magnum + (.venv)$ sudo bash -c "cat > /etc/magnum/magnum.conf << END_CONF + [bay] + enabled_definitions=magnum_vm_atomic_k8s,magnum_vm_coreos_k8s,example_template + END_CONF" + + # List installed templates, notice example template is now enabled + + (.venv)$ magnum-template-manage list-templates + Enabled Templates + example_template: /home/example/.venv/local/lib/python2.7/site-packages/ExampleTemplate-0.1-py2.7.egg/example_template/example.yaml + magnum_vm_atomic_k8s: /home/example/.venv/local/lib/python2.7/site-packages/magnum/templates/heat-kubernetes/kubecluster.yaml + magnum_vm_coreos_k8s: /home/example/.venv/local/lib/python2.7/site-packages/magnum/templates/heat-kubernetes/kubecluster-coreos.yaml + Disabled Templates + + # Use --details argument to get more details about each template + + (.venv)$ magnum-template-manage list-templates --details + Enabled Templates + example_template: /home/example/.venv/local/lib/python2.7/site-packages/ExampleTemplate-0.1-py2.7.egg/example_template/example.yaml + Platform OS CoE + vm example example_coe + magnum_vm_atomic_k8s: /home/example/.venv/local/lib/python2.7/site-packages/magnum/templates/heat-kubernetes/kubecluster.yaml + Platform OS CoE + vm fedora-atomic kubernetes + magnum_vm_coreos_k8s: /home/example/.venv/local/lib/python2.7/site-packages/magnum/templates/heat-kubernetes/kubecluster-coreos.yaml + Platform OS CoE + vm coreos kubernetes + Disabled Templates + diff --git a/contrib/templates/example/example_template/__init__.py b/contrib/templates/example/example_template/__init__.py new file mode 100644 index 0000000000..4aa167d788 --- /dev/null +++ b/contrib/templates/example/example_template/__init__.py @@ -0,0 +1,36 @@ +# Copyright (c) 2015 Rackspace Inc. +# +# 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. + +import os + +from magnum.conductor import template_definition + + +class ExampleTemplate(template_definition.BaseTemplateDefinition): + provides = [ + {'platform': 'vm', 'os': 'example', 'coe': 'example_coe'}, + {'platform': 'vm', 'os': 'example2', 'coe': 'example_coe'}, + ] + + def __init__(self): + super(ExampleTemplate, self).__init__() + + self.add_output('server_address', + bay_attr='api_address') + self.add_output('node_addresses', + bay_attr='node_addresses') + + def template_path(self): + return os.path.join(os.path.dirname(__file__), 'example.yaml') diff --git a/contrib/templates/example/example_template/example.yaml b/contrib/templates/example/example_template/example.yaml new file mode 100644 index 0000000000..5e48fa7c46 --- /dev/null +++ b/contrib/templates/example/example_template/example.yaml @@ -0,0 +1,45 @@ +heat_template_version: 2013-05-23 + +description: > + This is just an example template. It does not produce a usable bay. + +parameters: + # + # REQUIRED PARAMETERS + # + ssh_key_name: + type: string + description: name of ssh key to be provisioned on our server + + # + # OPTIONAL PARAMETERS + # + server_image: + type: string + default: centos-atomic + description: glance image used to boot the server + + server_flavor: + type: string + default: m1.small + description: flavor to use when booting the server + +resources: + + example_server: + type: "OS::Nova::Server" + properties: + image: + get_param: server_image + flavor: + get_param: server_flavor + key_name: + get_param: ssh_key_name + +outputs: + + server_address: + value: {get_attr: [example_server, accessIPv4]} + + node_addresses: + value: [] \ No newline at end of file diff --git a/contrib/templates/example/setup.py b/contrib/templates/example/setup.py new file mode 100644 index 0000000000..f3c9122e5b --- /dev/null +++ b/contrib/templates/example/setup.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +# Copyright (c) 2015 Rackspace Inc. +# +# 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. + +import setuptools + +setuptools.setup( + name="ExampleTemplate", + version="0.1", + packages=['example_template'], + install_requires=['magnum'], + package_data={ + 'example_template': ['example.yaml'] + }, + author="Me", + author_email="me@example.com", + description="This is an Example Template", + license="Apache", + keywords="magnum template example", + entry_points={ + 'magnum.template_definitions': [ + 'example_template = example_template:ExampleTemplate' + ] + } +) diff --git a/magnum/cmd/template_manage.py b/magnum/cmd/template_manage.py new file mode 100644 index 0000000000..9ff4fc700a --- /dev/null +++ b/magnum/cmd/template_manage.py @@ -0,0 +1,96 @@ +# +# 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. + +"""Starter script for magnum-template-manage.""" +import operator + +from oslo_config import cfg + +from magnum.conductor import template_definition as tdef +from magnum.openstack.common import cliutils +from magnum.openstack.common import log as logging + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + + +def is_enabled(name): + return name in CONF.bay.enabled_definitions + + +def print_rows(rows): + fields = ['name', 'enabled'] + field_labels = ['Name', 'Enabled'] + + if CONF.command.details: + fields.extend(['platform', 'os', 'coe']) + field_labels.extend(['Platform', 'OS', 'COE']) + if CONF.command.paths: + fields.append('path') + field_labels.append('Template Path') + + formatters = dict((key, operator.itemgetter(key)) for key in fields) + + cliutils.print_list(rows, fields, + formatters=formatters, + field_labels=field_labels) + + +def list_templates(): + rows = [] + + for entry_point, cls in tdef.TemplateDefinition.load_entry_points(): + name = entry_point.name + if ((is_enabled(name) and not CONF.command.disabled) or + (not is_enabled(name) and not CONF.command.enabled)): + definition = cls() + template = dict(name=name, enabled=is_enabled(name), + path=definition.template_path) + + if CONF.command.details: + for bay_type in definition.provides: + row = dict() + row.update(template) + row.update(bay_type) + rows.append(row) + else: + rows.append(template) + + print_rows(rows) + + +def add_command_parsers(subparsers): + parser = subparsers.add_parser('list-templates') + parser.set_defaults(func=list_templates) + + parser.add_argument('-d', '--details', action='store_true', + help='display the bay types provided by each template') + parser.add_argument('-p', '--paths', action='store_true', + help='display the path to each template file') + + group = parser.add_mutually_exclusive_group() + group.add_argument('--enabled', action='store_true', + help="display only enabled templates") + group.add_argument('--disabled', action='store_true', + help="display only disabled templates") + + +def main(): + command_opt = cfg.SubCommandOpt('command', + title='Command', + help='Available commands', + handler=add_command_parsers) + CONF.register_cli_opt(command_opt) + + CONF(project='magnum') + CONF.command.func() diff --git a/magnum/common/exception.py b/magnum/common/exception.py index e3398df899..5c2cdf091c 100644 --- a/magnum/common/exception.py +++ b/magnum/common/exception.py @@ -415,3 +415,15 @@ class ContainerException(Exception): class NotSupported(MagnumException): message = _("%(operation)s is not supported.") code = 400 + + +class BayTypeNotSupported(MagnumException): + message = _("Bay type (%(platform)s, %(os)s, %(coe)s) not supported.") + + +class BayTypeNotEnabled(MagnumException): + message = _("Bay type (%(platform)s, %(os)s, %(coe)s) not enabled.") + + +class RequiredParameterNotProvided(MagnumException): + message = _("Required parameter %(heat_param)s not provided.") diff --git a/magnum/conductor/handlers/bay_k8s_heat.py b/magnum/conductor/handlers/bay_k8s_heat.py index 281d3fd70e..218c30a38e 100644 --- a/magnum/conductor/handlers/bay_k8s_heat.py +++ b/magnum/conductor/handlers/bay_k8s_heat.py @@ -12,17 +12,14 @@ # License for the specific language governing permissions and limitations # under the License. -import requests -import uuid - from heatclient.common import template_utils from heatclient import exc from oslo_config import cfg from magnum.common import clients from magnum.common import exception -from magnum.common import paths from magnum.common import short_id +from magnum.conductor import template_definition as tdef from magnum import objects from magnum.openstack.common._i18n import _ from magnum.openstack.common._i18n import _LE @@ -32,17 +29,9 @@ from magnum.openstack.common import loopingcall k8s_heat_opts = [ - cfg.StrOpt('template_path', - default=paths.basedir_def('templates/heat-kubernetes/' - 'kubecluster.yaml'), - help=_( - 'Location of template to build a k8s cluster. ')), cfg.StrOpt('cluster_type', - default=None, + default='fedora-atomic', help=_('Cluster types are fedora-atomic, coreos, ironic.')), - cfg.StrOpt('discovery_token_url', - default=None, - help=_('coreos discovery token url.')), cfg.IntOpt('max_attempts', default=2000, help=('Number of attempts to query the Heat stack for ' @@ -60,62 +49,23 @@ cfg.CONF.register_opts(k8s_heat_opts, group='k8s_heat') LOG = logging.getLogger(__name__) -def _get_coreos_token(context): - if cfg.CONF.k8s_heat.cluster_type == 'coreos': - token = "" - discovery_url = cfg.CONF.k8s_heat.discovery_token_url - if discovery_url: - coreos_token_url = requests.get(discovery_url) - token = str(coreos_token_url.text.split('/')[3]) - else: - token = uuid.uuid4().hex - return token - else: - return None - - -def _extract_bay_definition(context, bay): +def _extract_template_definition(context, bay): baymodel = objects.BayModel.get_by_uuid(context, bay.baymodel_id) - token = _get_coreos_token(context) - bay_definition = { - 'ssh_key_name': baymodel.keypair_id, - 'external_network_id': baymodel.external_network_id, - } - if token is not None: - bay_definition['token'] = token - if baymodel.dns_nameserver: - bay_definition['dns_nameserver'] = baymodel.dns_nameserver - if baymodel.image_id: - bay_definition['server_image'] = baymodel.image_id - if baymodel.flavor_id: - bay_definition['server_flavor'] = baymodel.flavor_id - if baymodel.master_flavor_id: - bay_definition['master_flavor'] = baymodel.master_flavor_id - # TODO(yuanying): Add below lines if apiserver_port parameter is supported - # if baymodel.apiserver_port: - # bay_definition['apiserver_port'] = baymodel.apiserver_port - if bay.node_count is not None: - bay_definition['number_of_minions'] = str(bay.node_count) - if baymodel.docker_volume_size: - bay_definition['docker_volume_size'] = baymodel.docker_volume_size - if baymodel.fixed_network: - bay_definition['fixed_network'] = baymodel.fixed_network - if baymodel.ssh_authorized_key: - bay_definition['ssh_authorized_key'] = baymodel.ssh_authorized_key - - return bay_definition + definition = tdef.TemplateDefinition.get_template_definition('vm', + cfg.CONF.k8s_heat.cluster_type, + 'kubernetes') + return definition.extract_definition(baymodel, bay) def _create_stack(context, osc, bay): - bay_definition = _extract_bay_definition(context, bay) + template_path, heat_params = _extract_template_definition(context, bay) - tpl_files, template = template_utils.get_template_contents( - cfg.CONF.k8s_heat.template_path) + tpl_files, template = template_utils.get_template_contents(template_path) # Make sure no duplicate stack name stack_name = '%s-%s' % (bay.name, short_id.generate_id()) fields = { 'stack_name': stack_name, - 'parameters': bay_definition, + 'parameters': heat_params, 'template': template, 'files': dict(list(tpl_files.items())) } @@ -125,12 +75,11 @@ def _create_stack(context, osc, bay): def _update_stack(context, osc, bay): - bay_definition = _extract_bay_definition(context, bay) + template_path, heat_params = _extract_template_definition(context, bay) - tpl_files, template = template_utils.get_template_contents( - cfg.CONF.k8s_heat.template_path) + tpl_files, template = template_utils.get_template_contents(template_path) fields = { - 'parameters': bay_definition, + 'parameters': heat_params, 'template': template, 'files': dict(list(tpl_files.items())) } @@ -138,20 +87,11 @@ def _update_stack(context, osc, bay): return osc.heat().stacks.update(bay.stack_id, **fields) -def _parse_stack_outputs(outputs): - parsed_outputs = {} - - for output in outputs: - output_key = output["output_key"] - output_value = output["output_value"] - if output_key == "kube_minions_external": - parsed_outputs["kube_minions_external"] = output_value - if output_key == "kube_minions": - parsed_outputs["kube_minions"] = output_value - if output_key == "kube_master": - parsed_outputs["kube_master"] = output_value - - return parsed_outputs +def _update_stack_outputs(stack, bay): + definition = tdef.TemplateDefinition.get_template_definition('vm', + cfg.CONF.k8s_heat.cluster_type, + 'kubernetes') + return definition.update_outputs(stack, bay) class Handler(object): @@ -257,9 +197,7 @@ class HeatPoller(object): self.bay.destroy() raise loopingcall.LoopingCallDone() if (stack.stack_status in ['CREATE_COMPLETE', 'UPDATE_COMPLETE']): - parsed_outputs = _parse_stack_outputs(stack.outputs) - self.bay.api_address = parsed_outputs["kube_master"] - self.bay.node_addresses = parsed_outputs["kube_minions_external"] + _update_stack_outputs(stack, self.bay) self.bay.status = stack.stack_status self.bay.save() raise loopingcall.LoopingCallDone() diff --git a/magnum/conductor/template_definition.py b/magnum/conductor/template_definition.py new file mode 100644 index 0000000000..31d916ea89 --- /dev/null +++ b/magnum/conductor/template_definition.py @@ -0,0 +1,341 @@ +# Copyright 2014 Rackspace Inc. All rights reserved. +# +# 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. +import abc +import uuid + +from oslo_config import cfg +from pkg_resources import iter_entry_points +import requests +import six + +from magnum.common import exception +from magnum.openstack.common._i18n import _ + +from magnum.common import paths + + +template_def_opts = [ + cfg.StrOpt('k8s_atomic_template_path', + default=paths.basedir_def('templates/heat-kubernetes/' + 'kubecluster.yaml'), + deprecated_name='template_path', + deprecated_group='k8s_heat', + help=_( + 'Location of template to build a k8s cluster on atomic. ')), + cfg.StrOpt('k8s_coreos_template_path', + default=paths.basedir_def('templates/heat-kubernetes/' + 'kubecluster-coreos.yaml'), + help=_( + 'Location of template to build a k8s cluster on coreos. ')), + cfg.StrOpt('coreos_discovery_token_url', + default=None, + deprecated_name='discovery_token_url', + deprecated_group='k8s_heat', + help=_('coreos discovery token url.')), + cfg.ListOpt('enabled_definitions', + default=['magnum_vm_atomic_k8s', 'magnum_vm_coreos_k8s'], + help=_('Enabled bay definition entry points. ')), +] + +cfg.CONF.register_opts(template_def_opts, group='bay') + + +class ParameterMapping(object): + """A ParameterMapping is an association of a Heat parameter name with + an attribute on a Bay, Baymodel, or both. + + In the case of both baymodel_attr and bay_attr being set, the Baymodel + will be checked first and then Bay if the attribute isn't set on the + Baymodel. + + Parameters can also be set as 'required'. If a required parameter + isn't set, a RequiredArgumentNotProvided exception will be raised. + """ + def __init__(self, heat_param, baymodel_attr=None, + bay_attr=None, required=False, + param_type=lambda x: x): + self.heat_param = heat_param + self.baymodel_attr = baymodel_attr + self.bay_attr = bay_attr + self.required = required + self.param_type = param_type + + def set_param(self, params, baymodel, bay): + value = None + + if (self.baymodel_attr and + getattr(baymodel, self.baymodel_attr, None)): + value = getattr(baymodel, self.baymodel_attr) + elif (self.bay_attr and + getattr(bay, self.bay_attr, None)): + value = getattr(bay, self.bay_attr) + elif self.required: + kwargs = dict(heat_param=self.heat_param) + raise exception.RequiredParameterNotProvided(**kwargs) + + if value: + value = self.param_type(value) + params[self.heat_param] = value + + +class OutputMapping(object): + """An OutputMapping is an association of a Heat output with a key + Magnum understands. + """ + + def __init__(self, heat_output, bay_attr=None): + self.bay_attr = bay_attr + self.heat_output = heat_output + + def set_output(self, stack, bay): + for output in stack.outputs: + if output['output_key'] == self.heat_output: + setattr(bay, self.bay_attr, output['output_value']) + break + + +@six.add_metaclass(abc.ABCMeta) +class TemplateDefinition(object): + '''A TemplateDefinition is essentially a mapping between Magnum objects + and Heat templates. Each TemplateDefinition has a mapping of Heat + parameters. + ''' + definitions = None + provides = list() + + def __init__(self): + self.param_mappings = list() + self.output_mappings = list() + + @staticmethod + def load_entry_points(): + for entry_point in iter_entry_points('magnum.template_definitions'): + yield entry_point, entry_point.load() + + @classmethod + def get_template_definitions(cls): + '''Retrieves bay definitions from python entry_points. + + Example: + + With the following classes: + class TemplateDefinition1(TemplateDefinition): + provides = [ + ('platform1', 'os1', 'coe1') + ] + + class TemplateDefinition2(TemplateDefinition): + provides = [ + ('platform2', 'os2', 'coe2') + ] + + And the following entry_points: + + magnum.template_definitions = + template_name_1 = some.python.path:TemplateDefinition1 + template_name_2 = some.python.path:TemplateDefinition2 + + get_template_definitions will return: + { + (platform1, os1, coe1): + {'template_name_1': TemplateDefinition1}, + (platform2, os2, coe2): + {'template_name_2': TemplateDefinition2} + } + + :return: dict + ''' + + if not cls.definitions: + cls.definitions = dict() + for entry_point, def_class in cls.load_entry_points(): + for bay_type in def_class.provides: + bay_type_tuple = (bay_type['platform'], + bay_type['os'], + bay_type['coe']) + providers = cls.definitions.setdefault(bay_type_tuple, + dict()) + providers[entry_point.name] = def_class + + return cls.definitions + + @classmethod + def get_template_definition(cls, platform, os, coe): + '''Returns the enabled TemplateDefinition class for the provided + bay_type. + + With the following classes: + class TemplateDefinition1(TemplateDefinition): + provides = [ + ('platform1', 'os1', 'coe1') + ] + + class TemplateDefinition2(TemplateDefinition): + provides = [ + ('platform2', 'os2', 'coe2') + ] + + And the following entry_points: + + magnum.template_definitions = + template_name_1 = some.python.path:TemplateDefinition1 + template_name_2 = some.python.path:TemplateDefinition2 + + get_template_name_1_definition('platform2', 'os2', 'coe2') + will return: TemplateDefinition2 + + :param platform: The platform the bay definition will build on + :param os: The operation system the bay definition will build on + :param coe: The Container Orchestration Environment the bay will + produce + + :return: class + ''' + + definition_map = cls.get_template_definitions() + bay_type = (platform, os, coe) + + if bay_type not in definition_map: + raise exception.BayTypeNotSupported(platform=platform, os=os, + coe=coe) + type_definitions = definition_map[bay_type] + + for name in cfg.CONF.bay.enabled_definitions: + if name in type_definitions: + return type_definitions[name]() + + raise exception.BayTypeNotEnabled(platform=platform, os=os, coe=coe) + + def add_parameter(self, *args, **kwargs): + param = ParameterMapping(*args, **kwargs) + self.param_mappings.append(param) + + def add_output(self, *args, **kwargs): + output = OutputMapping(*args, **kwargs) + self.output_mappings.append(output) + + def get_params(self, baymodel, bay, extra_params=None): + """Pulls template parameters from Baymodel and Bay. + + :param baymodel: Baymodel to pull template parameters from + :param bay: Bay to pull template parameters from + :param extra_params: Any extra params to provide to the template + + :return: dict of template parameters + """ + template_params = dict() + + for mapping in self.param_mappings: + mapping.set_param(template_params, baymodel, bay) + + if extra_params: + template_params.update(extra_params) + + return template_params + + def update_outputs(self, stack, bay): + for output in self.output_mappings: + output.set_output(stack, bay) + + @abc.abstractproperty + def template_path(self): + pass + + def extract_definition(self, baymodel, bay, extra_params=None): + return self.template_path, self.get_params(baymodel, bay, + extra_params=extra_params) + + +class BaseTemplateDefinition(TemplateDefinition): + def __init__(self): + super(BaseTemplateDefinition, self).__init__() + self.add_parameter('ssh_key_name', + baymodel_attr='keypair_id', + required=True) + + self.add_parameter('server_image', + baymodel_attr='image_id') + self.add_parameter('server_flavor', + baymodel_attr='flavor_id') + + +class AtomicK8sTemplateDefinition(BaseTemplateDefinition): + provides = [ + {'platform': 'vm', 'os': 'fedora-atomic', 'coe': 'kubernetes'}, + ] + + def __init__(self): + super(AtomicK8sTemplateDefinition, self).__init__() + self.add_parameter('external_network_id', + baymodel_attr='external_network_id', + required=True) + + self.add_parameter('dns_nameserver', + baymodel_attr='dns_nameserver') + self.add_parameter('master_flavor', + baymodel_attr='master_flavor_id') + self.add_parameter('fixed_network', + baymodel_attr='fixed_network') + self.add_parameter('number_of_minions', + bay_attr='node_count', + param_type=str) + self.add_parameter('docker_volume_size', + baymodel_attr='docker_volume_size') + # TODO(yuanying): Add below lines if apiserver_port parameter + # is supported + # self.add_parameter('apiserver_port', + # baymodel_attr='apiserver_port') + + self.add_output('kube_master', + bay_attr='api_address') + self.add_output('kube_minions_external', + bay_attr='node_addresses') + + @property + def template_path(self): + return cfg.CONF.bay.k8s_atomic_template_path + + +class CoreOSK8sTemplateDefinition(AtomicK8sTemplateDefinition): + provides = [ + {'platform': 'vm', 'os': 'coreos', 'coe': 'kubernetes'}, + ] + + def __init__(self): + super(CoreOSK8sTemplateDefinition, self).__init__() + self.add_parameter('ssh_authorized_key', + baymodel_attr='ssh_authorized_key') + + @staticmethod + def get_token(): + discovery_url = cfg.CONF.bay.coreos_discovery_token_url + if discovery_url: + coreos_token_url = requests.get(discovery_url) + token = str(coreos_token_url.text.split('/')[3]) + else: + token = uuid.uuid4().hex + return token + + def get_params(self, baymodel, bay, extra_params=None): + if not extra_params: + extra_params = dict() + + extra_params['token'] = self.get_token() + + return super(CoreOSK8sTemplateDefinition, + self).get_params(baymodel, bay, extra_params=extra_params) + + @property + def template_path(self): + return cfg.CONF.bay.k8s_coreos_template_path diff --git a/magnum/openstack/common/cliutils.py b/magnum/openstack/common/cliutils.py new file mode 100644 index 0000000000..f163c5d862 --- /dev/null +++ b/magnum/openstack/common/cliutils.py @@ -0,0 +1,271 @@ +# Copyright 2012 Red Hat, Inc. +# +# 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. + +# W0603: Using the global statement +# W0621: Redefining name %s from outer scope +# pylint: disable=W0603,W0621 + +from __future__ import print_function + +import getpass +import inspect +import os +import sys +import textwrap + +from oslo_utils import encodeutils +from oslo_utils import strutils +import prettytable +import six +from six import moves + +from magnum.openstack.common._i18n import _ + + +class MissingArgs(Exception): + """Supplied arguments are not sufficient for calling a function.""" + def __init__(self, missing): + self.missing = missing + msg = _("Missing arguments: %s") % ", ".join(missing) + super(MissingArgs, self).__init__(msg) + + +def validate_args(fn, *args, **kwargs): + """Check that the supplied args are sufficient for calling a function. + + >>> validate_args(lambda a: None) + Traceback (most recent call last): + ... + MissingArgs: Missing argument(s): a + >>> validate_args(lambda a, b, c, d: None, 0, c=1) + Traceback (most recent call last): + ... + MissingArgs: Missing argument(s): b, d + + :param fn: the function to check + :param arg: the positional arguments supplied + :param kwargs: the keyword arguments supplied + """ + argspec = inspect.getargspec(fn) + + num_defaults = len(argspec.defaults or []) + required_args = argspec.args[:len(argspec.args) - num_defaults] + + def isbound(method): + return getattr(method, '__self__', None) is not None + + if isbound(fn): + required_args.pop(0) + + missing = [arg for arg in required_args if arg not in kwargs] + missing = missing[len(args):] + if missing: + raise MissingArgs(missing) + + +def arg(*args, **kwargs): + """Decorator for CLI args. + + Example: + + >>> @arg("name", help="Name of the new entity") + ... def entity_create(args): + ... pass + """ + def _decorator(func): + add_arg(func, *args, **kwargs) + return func + return _decorator + + +def env(*args, **kwargs): + """Returns the first environment variable set. + + If all are empty, defaults to '' or keyword arg `default`. + """ + for arg in args: + value = os.environ.get(arg) + if value: + return value + return kwargs.get('default', '') + + +def add_arg(func, *args, **kwargs): + """Bind CLI arguments to a shell.py `do_foo` function.""" + + if not hasattr(func, 'arguments'): + func.arguments = [] + + # NOTE(sirp): avoid dups that can occur when the module is shared across + # tests. + if (args, kwargs) not in func.arguments: + # Because of the semantics of decorator composition if we just append + # to the options list positional options will appear to be backwards. + func.arguments.insert(0, (args, kwargs)) + + +def unauthenticated(func): + """Adds 'unauthenticated' attribute to decorated function. + + Usage: + + >>> @unauthenticated + ... def mymethod(f): + ... pass + """ + func.unauthenticated = True + return func + + +def isunauthenticated(func): + """Checks if the function does not require authentication. + + Mark such functions with the `@unauthenticated` decorator. + + :returns: bool + """ + return getattr(func, 'unauthenticated', False) + + +def print_list(objs, fields, formatters=None, sortby_index=0, + mixed_case_fields=None, field_labels=None): + """Print a list or objects as a table, one row per object. + + :param objs: iterable of :class:`Resource` + :param fields: attributes that correspond to columns, in order + :param formatters: `dict` of callables for field formatting + :param sortby_index: index of the field for sorting table rows + :param mixed_case_fields: fields corresponding to object attributes that + have mixed case names (e.g., 'serverId') + :param field_labels: Labels to use in the heading of the table, default to + fields. + """ + formatters = formatters or {} + mixed_case_fields = mixed_case_fields or [] + field_labels = field_labels or fields + if len(field_labels) != len(fields): + raise ValueError(_("Field labels list %(labels)s has different number " + "of elements than fields list %(fields)s"), + {'labels': field_labels, 'fields': fields}) + + if sortby_index is None: + kwargs = {} + else: + kwargs = {'sortby': field_labels[sortby_index]} + pt = prettytable.PrettyTable(field_labels) + pt.align = 'l' + + for o in objs: + row = [] + for field in fields: + if field in formatters: + row.append(formatters[field](o)) + else: + if field in mixed_case_fields: + field_name = field.replace(' ', '_') + else: + field_name = field.lower().replace(' ', '_') + data = getattr(o, field_name, '') + row.append(data) + pt.add_row(row) + + if six.PY3: + print(encodeutils.safe_encode(pt.get_string(**kwargs)).decode()) + else: + print(encodeutils.safe_encode(pt.get_string(**kwargs))) + + +def print_dict(dct, dict_property="Property", wrap=0): + """Print a `dict` as a table of two columns. + + :param dct: `dict` to print + :param dict_property: name of the first column + :param wrap: wrapping for the second column + """ + pt = prettytable.PrettyTable([dict_property, 'Value']) + pt.align = 'l' + for k, v in six.iteritems(dct): + # convert dict to str to check length + if isinstance(v, dict): + v = six.text_type(v) + if wrap > 0: + v = textwrap.fill(six.text_type(v), wrap) + # if value has a newline, add in multiple rows + # e.g. fault with stacktrace + if v and isinstance(v, six.string_types) and r'\n' in v: + lines = v.strip().split(r'\n') + col1 = k + for line in lines: + pt.add_row([col1, line]) + col1 = '' + else: + pt.add_row([k, v]) + + if six.PY3: + print(encodeutils.safe_encode(pt.get_string()).decode()) + else: + print(encodeutils.safe_encode(pt.get_string())) + + +def get_password(max_password_prompts=3): + """Read password from TTY.""" + verify = strutils.bool_from_string(env("OS_VERIFY_PASSWORD")) + pw = None + if hasattr(sys.stdin, "isatty") and sys.stdin.isatty(): + # Check for Ctrl-D + try: + for __ in moves.range(max_password_prompts): + pw1 = getpass.getpass("OS Password: ") + if verify: + pw2 = getpass.getpass("Please verify: ") + else: + pw2 = pw1 + if pw1 == pw2 and pw1: + pw = pw1 + break + except EOFError: + pass + return pw + + +def service_type(stype): + """Adds 'service_type' attribute to decorated function. + + Usage: + + .. code-block:: python + + @service_type('volume') + def mymethod(f): + ... + """ + def inner(f): + f.service_type = stype + return f + return inner + + +def get_service_type(f): + """Retrieves service type from function.""" + return getattr(f, 'service_type', None) + + +def pretty_choice_list(l): + return ', '.join("'%s'" % i for i in l) + + +def exit(msg=''): + if msg: + print (msg, file=sys.stderr) + sys.exit(1) diff --git a/magnum/opts.py b/magnum/opts.py index 278f93d18a..3077b586ee 100644 --- a/magnum/opts.py +++ b/magnum/opts.py @@ -23,6 +23,7 @@ import magnum.common.magnum_keystoneclient import magnum.conductor.config import magnum.conductor.handlers.bay_k8s_heat import magnum.conductor.handlers.docker_conductor +import magnum.conductor.template_definition import magnum.db.sqlalchemy.models import magnum.openstack.common.eventlet_backdoor import magnum.openstack.common.log @@ -46,6 +47,7 @@ def list_opts(): magnum.openstack.common.periodic_task.periodic_opts, )), ('api', magnum.api.app.API_SERVICE_OPTS), + ('bay', magnum.conductor.template_definition.template_def_opts), ('conductor', magnum.conductor.config.SERVICE_OPTS), ('database', magnum.db.sqlalchemy.models.sql_opts), ('docker', magnum.conductor.handlers.docker_conductor.docker_opts), diff --git a/magnum/tests/unit/conductor/handlers/test_bay_k8s_heat.py b/magnum/tests/unit/conductor/handlers/test_bay_k8s_heat.py index 929024bc31..68a6bef008 100644 --- a/magnum/tests/unit/conductor/handlers/test_bay_k8s_heat.py +++ b/magnum/tests/unit/conductor/handlers/test_bay_k8s_heat.py @@ -52,14 +52,15 @@ class TestBayK8sHeat(base.TestCase): } @patch('magnum.objects.BayModel.get_by_uuid') - def test_extract_bay_definition(self, + def test_extract_template_definition(self, mock_objects_baymodel_get_by_uuid): baymodel = objects.BayModel(self.context, **self.baymodel_dict) mock_objects_baymodel_get_by_uuid.return_value = baymodel bay = objects.Bay(self.context, **self.bay_dict) - bay_definition = bay_k8s_heat._extract_bay_definition(self.context, - bay) + (template_path, + definition) = bay_k8s_heat._extract_template_definition(self.context, + bay) expected = { 'ssh_key_name': 'keypair_id', @@ -71,29 +72,29 @@ class TestBayK8sHeat(base.TestCase): 'number_of_minions': '1', 'fixed_network': 'private', 'docker_volume_size': 20, - 'ssh_authorized_key': 'ssh_authorized_key', } - self.assertEqual(expected, bay_definition) + self.assertEqual(expected, definition) @patch('requests.get') @patch('magnum.objects.BayModel.get_by_uuid') - def test_extract_bay_definition_coreos_with_disovery(self, + def test_extract_template_definition_coreos_with_disovery(self, mock_objects_baymodel_get_by_uuid, reqget): cfg.CONF.set_override('cluster_type', 'coreos', group='k8s_heat') - cfg.CONF.set_override('discovery_token_url', + cfg.CONF.set_override('coreos_discovery_token_url', 'http://tokentest', - group='k8s_heat') + group='bay') mock_req = mock.MagicMock(text='/h1/h2/h3') reqget.return_value = mock_req baymodel = objects.BayModel(self.context, **self.baymodel_dict) mock_objects_baymodel_get_by_uuid.return_value = baymodel bay = objects.Bay(self.context, **self.bay_dict) - bay_definition = bay_k8s_heat._extract_bay_definition(self.context, - bay) + (template_path, + definition) = bay_k8s_heat._extract_template_definition(self.context, + bay) expected = { 'ssh_key_name': 'keypair_id', @@ -108,27 +109,28 @@ class TestBayK8sHeat(base.TestCase): 'ssh_authorized_key': 'ssh_authorized_key', 'token': 'h3' } - self.assertEqual(expected, bay_definition) + self.assertEqual(expected, definition) @patch('uuid.uuid4') @patch('magnum.objects.BayModel.get_by_uuid') - def test_extract_bay_definition_coreos_no_discoveryurl(self, + def test_extract_template_definition_coreos_no_discoveryurl(self, mock_objects_baymodel_get_by_uuid, mock_uuid): cfg.CONF.set_override('cluster_type', 'coreos', group='k8s_heat') - cfg.CONF.set_override('discovery_token_url', + cfg.CONF.set_override('coreos_discovery_token_url', None, - group='k8s_heat') + group='bay') mock_uuid.return_value = mock.MagicMock( hex='ba3d1866282848ddbedc76112110c208') baymodel = objects.BayModel(self.context, **self.baymodel_dict) mock_objects_baymodel_get_by_uuid.return_value = baymodel bay = objects.Bay(self.context, **self.bay_dict) - bay_definition = bay_k8s_heat._extract_bay_definition(self.context, - bay) + (template_path, + definition) = bay_k8s_heat._extract_template_definition(self.context, + bay) expected = { 'ssh_key_name': 'keypair_id', @@ -143,10 +145,10 @@ class TestBayK8sHeat(base.TestCase): 'ssh_authorized_key': 'ssh_authorized_key', 'token': 'ba3d1866282848ddbedc76112110c208' } - self.assertEqual(expected, bay_definition) + self.assertEqual(expected, definition) @patch('magnum.objects.BayModel.get_by_uuid') - def test_extract_bay_definition_without_dns(self, + def test_extract_template_definition_without_dns(self, mock_objects_baymodel_get_by_uuid): baymodel_dict = self.baymodel_dict baymodel_dict['dns_nameserver'] = None @@ -154,8 +156,9 @@ class TestBayK8sHeat(base.TestCase): mock_objects_baymodel_get_by_uuid.return_value = baymodel bay = objects.Bay(self.context, **self.bay_dict) - bay_definition = bay_k8s_heat._extract_bay_definition(self.context, - bay) + (template_path, + definition) = bay_k8s_heat._extract_template_definition(self.context, + bay) expected = { 'ssh_key_name': 'keypair_id', @@ -165,13 +168,12 @@ class TestBayK8sHeat(base.TestCase): 'master_flavor': 'master_flavor_id', 'number_of_minions': '1', 'fixed_network': 'private', - 'docker_volume_size': 20, - 'ssh_authorized_key': 'ssh_authorized_key', + 'docker_volume_size': 20 } - self.assertEqual(expected, bay_definition) + self.assertEqual(expected, definition) @patch('magnum.objects.BayModel.get_by_uuid') - def test_extract_bay_definition_without_server_image(self, + def test_extract_template_definition_without_server_image(self, mock_objects_baymodel_get_by_uuid): baymodel_dict = self.baymodel_dict baymodel_dict['image_id'] = None @@ -179,8 +181,9 @@ class TestBayK8sHeat(base.TestCase): mock_objects_baymodel_get_by_uuid.return_value = baymodel bay = objects.Bay(self.context, **self.bay_dict) - bay_definition = bay_k8s_heat._extract_bay_definition(self.context, - bay) + (template_path, + definition) = bay_k8s_heat._extract_template_definition(self.context, + bay) expected = { 'ssh_key_name': 'keypair_id', @@ -190,13 +193,12 @@ class TestBayK8sHeat(base.TestCase): 'master_flavor': 'master_flavor_id', 'number_of_minions': '1', 'fixed_network': 'private', - 'docker_volume_size': 20, - 'ssh_authorized_key': 'ssh_authorized_key', + 'docker_volume_size': 20 } - self.assertEqual(expected, bay_definition) + self.assertEqual(expected, definition) @patch('magnum.objects.BayModel.get_by_uuid') - def test_extract_bay_definition_without_server_flavor(self, + def test_extract_template_definition_without_server_flavor(self, mock_objects_baymodel_get_by_uuid): baymodel_dict = self.baymodel_dict baymodel_dict['flavor_id'] = None @@ -204,8 +206,9 @@ class TestBayK8sHeat(base.TestCase): mock_objects_baymodel_get_by_uuid.return_value = baymodel bay = objects.Bay(self.context, **self.bay_dict) - bay_definition = bay_k8s_heat._extract_bay_definition(self.context, - bay) + (template_path, + definition) = bay_k8s_heat._extract_template_definition(self.context, + bay) expected = { 'ssh_key_name': 'keypair_id', @@ -216,12 +219,11 @@ class TestBayK8sHeat(base.TestCase): 'number_of_minions': '1', 'fixed_network': 'private', 'docker_volume_size': 20, - 'ssh_authorized_key': 'ssh_authorized_key', } - self.assertEqual(expected, bay_definition) + self.assertEqual(expected, definition) @patch('magnum.objects.BayModel.get_by_uuid') - def test_extract_bay_definition_without_docker_volume_size(self, + def test_extract_template_definition_without_docker_volume_size(self, mock_objects_baymodel_get_by_uuid): baymodel_dict = self.baymodel_dict baymodel_dict['docker_volume_size'] = None @@ -229,8 +231,9 @@ class TestBayK8sHeat(base.TestCase): mock_objects_baymodel_get_by_uuid.return_value = baymodel bay = objects.Bay(self.context, **self.bay_dict) - bay_definition = bay_k8s_heat._extract_bay_definition(self.context, - bay) + (template_path, + definition) = bay_k8s_heat._extract_template_definition(self.context, + bay) expected = { 'ssh_key_name': 'keypair_id', @@ -241,12 +244,11 @@ class TestBayK8sHeat(base.TestCase): 'fixed_network': 'private', 'master_flavor': 'master_flavor_id', 'number_of_minions': '1', - 'ssh_authorized_key': 'ssh_authorized_key', } - self.assertEqual(expected, bay_definition) + self.assertEqual(expected, definition) @patch('magnum.objects.BayModel.get_by_uuid') - def test_extract_bay_definition_without_fixed_network(self, + def test_extract_template_definition_without_fixed_network(self, mock_objects_baymodel_get_by_uuid): baymodel_dict = self.baymodel_dict baymodel_dict['fixed_network'] = None @@ -254,8 +256,9 @@ class TestBayK8sHeat(base.TestCase): mock_objects_baymodel_get_by_uuid.return_value = baymodel bay = objects.Bay(self.context, **self.bay_dict) - bay_definition = bay_k8s_heat._extract_bay_definition(self.context, - bay) + (template_path, + definition) = bay_k8s_heat._extract_template_definition(self.context, + bay) expected = { 'ssh_key_name': 'keypair_id', @@ -266,12 +269,11 @@ class TestBayK8sHeat(base.TestCase): 'server_flavor': 'flavor_id', 'number_of_minions': '1', 'docker_volume_size': 20, - 'ssh_authorized_key': 'ssh_authorized_key', } - self.assertEqual(expected, bay_definition) + self.assertEqual(expected, definition) @patch('magnum.objects.BayModel.get_by_uuid') - def test_extract_bay_definition_without_master_flavor(self, + def test_extract_template_definition_without_master_flavor(self, mock_objects_baymodel_get_by_uuid): baymodel_dict = self.baymodel_dict baymodel_dict['master_flavor_id'] = None @@ -279,8 +281,9 @@ class TestBayK8sHeat(base.TestCase): mock_objects_baymodel_get_by_uuid.return_value = baymodel bay = objects.Bay(self.context, **self.bay_dict) - bay_definition = bay_k8s_heat._extract_bay_definition(self.context, - bay) + (template_path, + definition) = bay_k8s_heat._extract_template_definition(self.context, + bay) expected = { 'ssh_key_name': 'keypair_id', @@ -291,21 +294,24 @@ class TestBayK8sHeat(base.TestCase): 'number_of_minions': '1', 'fixed_network': 'private', 'docker_volume_size': 20, - 'ssh_authorized_key': 'ssh_authorized_key', } - self.assertEqual(expected, bay_definition) + self.assertEqual(expected, definition) @patch('magnum.objects.BayModel.get_by_uuid') - def test_extract_bay_definition_without_ssh_authorized_key(self, + def test_extract_template_definition_without_ssh_authorized_key(self, mock_objects_baymodel_get_by_uuid): + cfg.CONF.set_override('cluster_type', + 'coreos', + group='k8s_heat') baymodel_dict = self.baymodel_dict baymodel_dict['ssh_authorized_key'] = None baymodel = objects.BayModel(self.context, **baymodel_dict) mock_objects_baymodel_get_by_uuid.return_value = baymodel bay = objects.Bay(self.context, **self.bay_dict) - bay_definition = bay_k8s_heat._extract_bay_definition(self.context, - bay) + (template_path, + definition) = bay_k8s_heat._extract_template_definition(self.context, + bay) expected = { 'ssh_key_name': 'keypair_id', @@ -318,10 +324,12 @@ class TestBayK8sHeat(base.TestCase): 'fixed_network': 'private', 'docker_volume_size': 20, } - self.assertEqual(expected, bay_definition) + self.assertIn('token', definition) + del definition['token'] + self.assertEqual(expected, definition) @patch('magnum.objects.BayModel.get_by_uuid') - def test_extract_bay_definition_without_apiserver_port(self, + def test_extract_template_definition_without_apiserver_port(self, mock_objects_baymodel_get_by_uuid): baymodel_dict = self.baymodel_dict baymodel_dict['apiserver_port'] = None @@ -329,8 +337,9 @@ class TestBayK8sHeat(base.TestCase): mock_objects_baymodel_get_by_uuid.return_value = baymodel bay = objects.Bay(self.context, **self.bay_dict) - bay_definition = bay_k8s_heat._extract_bay_definition(self.context, - bay) + (template_path, + definition) = bay_k8s_heat._extract_template_definition(self.context, + bay) expected = { 'ssh_key_name': 'keypair_id', @@ -342,12 +351,11 @@ class TestBayK8sHeat(base.TestCase): 'number_of_minions': '1', 'fixed_network': 'private', 'docker_volume_size': 20, - 'ssh_authorized_key': 'ssh_authorized_key', } - self.assertEqual(expected, bay_definition) + self.assertEqual(expected, definition) @patch('magnum.objects.BayModel.get_by_uuid') - def test_extract_bay_definition_without_node_count(self, + def test_extract_template_definition_without_node_count(self, mock_objects_baymodel_get_by_uuid): bay_dict = self.bay_dict bay_dict['node_count'] = None @@ -355,8 +363,9 @@ class TestBayK8sHeat(base.TestCase): mock_objects_baymodel_get_by_uuid.return_value = baymodel bay = objects.Bay(self.context, **bay_dict) - bay_definition = bay_k8s_heat._extract_bay_definition(self.context, - bay) + (template_path, + definition) = bay_k8s_heat._extract_template_definition(self.context, + bay) expected = { 'ssh_key_name': 'keypair_id', @@ -367,46 +376,40 @@ class TestBayK8sHeat(base.TestCase): 'fixed_network': 'private', 'master_flavor': 'master_flavor_id', 'docker_volume_size': 20, - 'ssh_authorized_key': 'ssh_authorized_key', } - self.assertEqual(expected, bay_definition) + self.assertEqual(expected, definition) - def test_parse_stack_outputs(self): + def test_update_stack_outputs(self): expected_api_address = 'api_address' - expected_minion_address = ['minion', 'address'] - expected_minion_external_address = ['ex_minion', 'address'] - expected_return_value = { - 'kube_master': expected_api_address, - 'kube_minions': expected_minion_address, - 'kube_minions_external': expected_minion_external_address - } + expected_node_addresses = ['ex_minion', 'address'] outputs = [ { - "output_value": expected_minion_external_address, + "output_value": expected_node_addresses, "description": "No description given", "output_key": "kube_minions_external" }, - { - "output_value": expected_minion_address, - "description": "No description given", - "output_key": "kube_minions" - }, { "output_value": expected_api_address, "description": "No description given", "output_key": "kube_master" } ] + mock_stack = mock.MagicMock() + mock_stack.outputs = outputs + mock_bay = mock.MagicMock() - parsed_outputs = bay_k8s_heat._parse_stack_outputs(outputs) - self.assertEqual(expected_return_value, parsed_outputs) + bay_k8s_heat._update_stack_outputs(mock_stack, mock_bay) + + self.assertEqual(mock_bay.api_address, expected_api_address) + self.assertEqual(mock_bay.node_addresses, expected_node_addresses) @patch('magnum.common.short_id.generate_id') @patch('heatclient.common.template_utils.get_template_contents') - @patch('magnum.conductor.handlers.bay_k8s_heat._extract_bay_definition') + @patch('magnum.conductor.handlers.bay_k8s_heat' + '._extract_template_definition') def test_create_stack(self, - mock_extract_bay_definition, + mock_extract_template_definition, mock_get_template_contents, mock_generate_id): @@ -420,7 +423,8 @@ class TestBayK8sHeat(base.TestCase): mock_tpl_files.items.return_value = exptected_files mock_get_template_contents.return_value = [ mock_tpl_files, expected_template_contents] - mock_extract_bay_definition.return_value = {} + mock_extract_template_definition.return_value = ('template/path', + {}) mock_heat_client = mock.MagicMock() mock_osc = mock.MagicMock() mock_osc.heat.return_value = mock_heat_client @@ -438,9 +442,10 @@ class TestBayK8sHeat(base.TestCase): mock_heat_client.stacks.create.assert_called_once_with(**expected_args) @patch('heatclient.common.template_utils.get_template_contents') - @patch('magnum.conductor.handlers.bay_k8s_heat._extract_bay_definition') + @patch('magnum.conductor.handlers.bay_k8s_heat' + '._extract_template_definition') def test_update_stack(self, - mock_extract_bay_definition, + mock_extract_template_definition, mock_get_template_contents): mock_stack_id = 'xx-xx-xx-xx' @@ -451,7 +456,8 @@ class TestBayK8sHeat(base.TestCase): mock_tpl_files.items.return_value = exptected_files mock_get_template_contents.return_value = [ mock_tpl_files, expected_template_contents] - mock_extract_bay_definition.return_value = {} + mock_extract_template_definition.return_value = ('template/path', + {}) mock_heat_client = mock.MagicMock() mock_osc = mock.MagicMock() mock_osc.heat.return_value = mock_heat_client diff --git a/magnum/tests/unit/conductor/test_template_definition.py b/magnum/tests/unit/conductor/test_template_definition.py new file mode 100644 index 0000000000..5179a9f2cb --- /dev/null +++ b/magnum/tests/unit/conductor/test_template_definition.py @@ -0,0 +1,71 @@ +# Copyright 2015 Rackspace Inc. All rights reserved. +# +# 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. + +import mock +from oslo_config import cfg + +from magnum.common import exception +from magnum.conductor import template_definition as tdef +from magnum.tests import base + + +class TemplateDefinitionTestCase(base.TestCase): + def test_get_template_definitions(self): + defs = tdef.TemplateDefinition.get_template_definitions() + + vm_atomic_k8s = defs[('vm', 'fedora-atomic', 'kubernetes')] + vm_coreos_k8s = defs[('vm', 'coreos', 'kubernetes')] + + self.assertEqual(len(vm_atomic_k8s), 1) + self.assertEqual(vm_atomic_k8s['magnum_vm_atomic_k8s'], + tdef.AtomicK8sTemplateDefinition) + self.assertEqual(len(vm_coreos_k8s), 1) + self.assertEqual(vm_coreos_k8s['magnum_vm_coreos_k8s'], + tdef.CoreOSK8sTemplateDefinition) + + def test_get_vm_atomic_kubernetes_definition(self): + definition = tdef.TemplateDefinition.get_template_definition('vm', + 'fedora-atomic', 'kubernetes') + + self.assertIsInstance(definition, + tdef.AtomicK8sTemplateDefinition) + + def test_get_vm_coreos_kubernetes_definition(self): + definition = tdef.TemplateDefinition.get_template_definition('vm', + 'coreos', 'kubernetes') + + self.assertIsInstance(definition, + tdef.CoreOSK8sTemplateDefinition) + + def test_get_definition_not_supported(self): + self.assertRaises(exception.BayTypeNotSupported, + tdef.TemplateDefinition.get_template_definition, + 'vm', 'not_supported', 'kubernetes') + + def test_get_definition_not_enabled(self): + cfg.CONF.set_override('enabled_definitions', + ['magnum_vm_atomic_k8s'], + group='bay') + self.assertRaises(exception.BayTypeNotEnabled, + tdef.TemplateDefinition.get_template_definition, + 'vm', 'coreos', 'kubernetes') + + def test_required_param_not_set(self): + param = tdef.ParameterMapping('test', baymodel_attr='test', + required=True) + mock_baymodel = mock.MagicMock() + mock_baymodel.test = None + + self.assertRaises(exception.RequiredParameterNotProvided, + param.set_param, {}, mock_baymodel, None) diff --git a/openstack-common.conf b/openstack-common.conf index 3430a68eb6..218eff1085 100644 --- a/openstack-common.conf +++ b/openstack-common.conf @@ -1,6 +1,7 @@ [DEFAULT] # The list of modules to copy from oslo-incubator.git +module=cliutils module=eventlet_backdoor module=loopingcall module=periodic_task diff --git a/setup.cfg b/setup.cfg index 7456522f86..8ca103bbec 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,9 +48,14 @@ console_scripts = magnum-api = magnum.cmd.api:main magnum-conductor = magnum.cmd.conductor:main magnum-db-manage = magnum.cmd.db_manage:main + magnum-template-manage = magnum.cmd.template_manage:main oslo.config.opts = magnum = magnum.opts:list_opts +magnum.template_definitions = + magnum_vm_atomic_k8s = magnum.conductor.template_definition:AtomicK8sTemplateDefinition + magnum_vm_coreos_k8s = magnum.conductor.template_definition:CoreOSK8sTemplateDefinition + [wheel] universal = 1