Browse Source

Add Template Definitions

The idea here is to provide a way for magnum to discover and interact
with templates meant to build bays. Template definition discovery is
done through python entry_points, and each class lists the bay_types
it can provide. Each template definition contains a mapping of magnum
object attributes and heat template parameters/outputs.

This will be useful for not only allowing different CoEs, OSes, and
platforms. But can also provide the discovery mechanism for templates
once they are pulled into their own repository.

Partial-Implements: bp multiple-bay-templates

Change-Id: Ia596657856cd861c94e58dcd65acae0677a36d73
changes/66/167666/12
Andrew Melton 7 years ago
parent
commit
3a8ab855a8
  1. 105
      contrib/templates/example/README.rst
  2. 36
      contrib/templates/example/example_template/__init__.py
  3. 45
      contrib/templates/example/example_template/example.yaml
  4. 37
      contrib/templates/example/setup.py
  5. 96
      magnum/cmd/template_manage.py
  6. 12
      magnum/common/exception.py
  7. 100
      magnum/conductor/handlers/bay_k8s_heat.py
  8. 341
      magnum/conductor/template_definition.py
  9. 271
      magnum/openstack/common/cliutils.py
  10. 2
      magnum/opts.py
  11. 176
      magnum/tests/unit/conductor/handlers/test_bay_k8s_heat.py
  12. 71
      magnum/tests/unit/conductor/test_template_definition.py
  13. 1
      openstack-common.conf
  14. 5
      setup.cfg

105
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

36
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')

45
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: []

37
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'
]
}
)

96
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()

12
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.")

100
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()

341
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

271
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)

2
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),

176
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,