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
This commit is contained in:
parent
bc3bc6190d
commit
3a8ab855a8
105
contrib/templates/example/README.rst
Normal file
105
contrib/templates/example/README.rst
Normal file
@ -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
Normal file
36
contrib/templates/example/example_template/__init__.py
Normal file
@ -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
Normal file
45
contrib/templates/example/example_template/example.yaml
Normal file
@ -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
Normal file
37
contrib/templates/example/setup.py
Normal file
@ -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
Normal file
96
magnum/cmd/template_manage.py
Normal file
@ -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()
|
@ -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.")
|
||||
|
@ -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
Normal file
341
magnum/conductor/template_definition.py
Normal file
@ -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
Normal file
271
magnum/openstack/common/cliutils.py
Normal file
@ -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)
|
@ -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),
|
||||
|
@ -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
|
||||
|
71
magnum/tests/unit/conductor/test_template_definition.py
Normal file
71
magnum/tests/unit/conductor/test_template_definition.py
Normal file
@ -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)
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user