Configuration file plugins support for nailgun

- Task serialization logic for pre/post hooks
    - PluginSerializer object added
    - API for interaction with Plugins db schema
    General API requests supported
    GET/POST /plugins
    GET/PUT/DELETE /plugins/<plugin_id>
    - Plugins and ClustersPlugins db models added
    ClusterPlugins used to identify enabled plugins for a
    given cluster
    - Configuration uploading logic stored in Plugin wrapper

Co-authored-by: Dima Shulyak <dshulyak@mirantis.com>
Co-authored-by: Evgeniy L <eli@mirantis.com>

implements: blueprint cinder-neutron-plugins-in-fuel
Change-Id: I986f5be9ed3c3adaf7583d1bfc546cbe705db9ec
This commit is contained in:
Evgeniy L 2014-10-15 18:00:18 +04:00
parent 52e777a8de
commit 7bf81cef44
29 changed files with 1165 additions and 47 deletions

View File

@ -5,7 +5,7 @@ MarkupSafe==0.18
Paste==1.7.5.1
PyYAML==3.10
requests>=1.2.3
SQLAlchemy==0.7.9
SQLAlchemy>=0.9.4
Shotgun==0.1.0
alembic==0.6.2
amqplib==1.0.2

View File

@ -272,7 +272,6 @@ class SingleHandler(BaseHandler):
self.validator.validate_update,
instance=obj
)
self.single.update(obj, data)
return self.single.to_json(obj)

View File

@ -38,7 +38,6 @@ from nailgun.task.manager import ClusterDeletionManager
from nailgun.task.manager import ResetEnvironmentTaskManager
from nailgun.task.manager import StopDeploymentTaskManager
from nailgun.task.manager import UpdateEnvironmentTaskManager
from nailgun import utils
class ClusterHandler(SingleHandler):
@ -130,9 +129,7 @@ class ClusterAttributesHandler(BaseHandler):
if not cluster.attributes:
raise self.http(500, "No attributes found!")
return {
"editable": cluster.attributes.editable
}
return objects.Cluster.get_editable_attributes(cluster)
@content_json
def PUT(self, cluster_id):
@ -151,12 +148,8 @@ class ClusterAttributesHandler(BaseHandler):
"after, or in deploy.")
data = self.checked_data()
for key, value in data.iteritems():
setattr(cluster.attributes, key, value)
objects.Cluster.add_pending_changes(cluster, "attributes")
return {"editable": cluster.attributes.editable}
objects.Cluster.update_attributes(cluster, data)
return objects.Cluster.get_editable_attributes(cluster)
@content_json
def PATCH(self, cluster_id):
@ -176,12 +169,8 @@ class ClusterAttributesHandler(BaseHandler):
"after, or in deploy.")
data = self.checked_data()
cluster.attributes.editable = utils.dict_merge(
cluster.attributes.editable, data['editable'])
objects.Cluster.add_pending_changes(cluster, "attributes")
return {"editable": cluster.attributes.editable}
objects.Cluster.patch_attributes(cluster, data)
return objects.Cluster.get_editable_attributes(cluster)
class ClusterAttributesDefaultsHandler(BaseHandler):

View File

@ -27,6 +27,8 @@ from nailgun.logger import logger
from nailgun import objects
from nailgun.orchestrator import deployment_serializers
from nailgun.orchestrator.plugins_serializers import post_deployment_serialize
from nailgun.orchestrator.plugins_serializers import pre_deployment_serialize
from nailgun.orchestrator import provisioning_serializers
from nailgun.task.helpers import TaskHelper
from nailgun.task.manager import DeploymentTaskManager
@ -64,9 +66,6 @@ class DefaultOrchestratorInfo(NodesFilterMixin, BaseHandler):
Need to redefine serializer variable
"""
# Override this attribute
_serializer = None
@content_json
def GET(self, cluster_id):
""":returns: JSONized default data which will be passed to orchestrator
@ -75,8 +74,11 @@ class DefaultOrchestratorInfo(NodesFilterMixin, BaseHandler):
"""
cluster = self.get_object_or_404(objects.Cluster, cluster_id)
nodes = self.get_nodes(cluster)
return self._serializer.serialize(
cluster, nodes, ignore_customized=True)
return self._serialize(cluster, nodes)
def _serialize(self, cluster, nodes):
raise NotImplementedError('Override the method')
class OrchestratorInfo(BaseHandler):
@ -133,7 +135,9 @@ class OrchestratorInfo(BaseHandler):
class DefaultProvisioningInfo(DefaultOrchestratorInfo):
_serializer = provisioning_serializers
def _serialize(self, cluster, nodes):
return provisioning_serializers.serialize(
cluster, nodes, ignore_customized=True)
def get_default_nodes(self, cluster):
return TaskHelper.nodes_to_provision(cluster)
@ -141,7 +145,27 @@ class DefaultProvisioningInfo(DefaultOrchestratorInfo):
class DefaultDeploymentInfo(DefaultOrchestratorInfo):
_serializer = deployment_serializers
def _serialize(self, cluster, nodes):
return deployment_serializers.serialize(
cluster, nodes, ignore_customized=True)
def get_default_nodes(self, cluster):
return TaskHelper.nodes_to_deploy(cluster)
class DefaultPrePluginsHooksInfo(DefaultOrchestratorInfo):
def _serialize(self, cluster, nodes):
return pre_deployment_serialize(cluster, nodes)
def get_default_nodes(self, cluster):
return TaskHelper.nodes_to_deploy(cluster)
class DefaultPostPluginsHooksInfo(DefaultOrchestratorInfo):
def _serialize(self, cluster, nodes):
return post_deployment_serialize(cluster, nodes)
def get_default_nodes(self, cluster):
return TaskHelper.nodes_to_deploy(cluster)

View File

@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Copyright 2014 Mirantis, 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.
from nailgun.api.v1.handlers import base
from nailgun.api.v1.handlers.base import content_json
from nailgun.api.v1.validators import plugin
from nailgun import objects
class PluginHandler(base.SingleHandler):
validator = plugin.PluginValidator
single = objects.Plugin
class PluginCollectionHandler(base.CollectionHandler):
collection = objects.PluginCollection
validator = plugin.PluginValidator
@content_json
def POST(self):
""":returns: JSONized REST object.
:http: * 201 (object successfully created)
* 400 (invalid object data specified)
* 409 (object with such parameters already exists)
"""
data = self.checked_data()
obj = self.collection.single.get_by_name_version(
data['name'], data['version'])
if obj:
raise self.http(409, self.collection.single.to_json(obj))
return super(PluginCollectionHandler, self).POST()

View File

@ -55,6 +55,9 @@ from nailgun.api.v1.handlers.node import NodeCollectionHandler
from nailgun.api.v1.handlers.node import NodeHandler
from nailgun.api.v1.handlers.node import NodesAllocationStatsHandler
from nailgun.api.v1.handlers.plugin import PluginCollectionHandler
from nailgun.api.v1.handlers.plugin import PluginHandler
from nailgun.api.v1.handlers.node import NodeCollectionNICsDefaultHandler
from nailgun.api.v1.handlers.node import NodeCollectionNICsHandler
from nailgun.api.v1.handlers.node import NodeNICsDefaultHandler
@ -64,12 +67,15 @@ from nailgun.api.v1.handlers.notifications import NotificationCollectionHandler
from nailgun.api.v1.handlers.notifications import NotificationHandler
from nailgun.api.v1.handlers.orchestrator import DefaultDeploymentInfo
from nailgun.api.v1.handlers.orchestrator import DefaultPostPluginsHooksInfo
from nailgun.api.v1.handlers.orchestrator import DefaultPrePluginsHooksInfo
from nailgun.api.v1.handlers.orchestrator import DefaultProvisioningInfo
from nailgun.api.v1.handlers.orchestrator import DeploymentInfo
from nailgun.api.v1.handlers.orchestrator import DeploySelectedNodes
from nailgun.api.v1.handlers.orchestrator import ProvisioningInfo
from nailgun.api.v1.handlers.orchestrator import ProvisionSelectedNodes
from nailgun.api.v1.handlers.registration import FuelKeyHandler
from nailgun.api.v1.handlers.release import ReleaseCollectionHandler
from nailgun.api.v1.handlers.release import ReleaseHandler
@ -124,6 +130,10 @@ urls = (
DefaultProvisioningInfo,
r'/clusters/(?P<cluster_id>\d+)/generated/?$',
ClusterGeneratedData,
r'/clusters/(?P<cluster_id>\d+)/orchestrator/plugins_pre_hooks/?$',
DefaultPrePluginsHooksInfo,
r'/clusters/(?P<cluster_id>\d+)/orchestrator/plugins_post_hooks/?$',
DefaultPostPluginsHooksInfo,
r'/clusters/(?P<cluster_id>\d+)/provision/?$',
ProvisionSelectedNodes,
@ -168,6 +178,11 @@ urls = (
r'/tasks/(?P<obj_id>\d+)/?$',
TaskHandler,
r'/plugins/(?P<obj_id>\d+)/?$',
PluginHandler,
r'/plugins/?$',
PluginCollectionHandler,
r'/notifications/?$',
NotificationCollectionHandler,
r'/notifications/(?P<obj_id>\d+)/?$',

View File

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Copyright 2014 Mirantis, 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.
from nailgun.api.v1.validators.base import BasicValidator
from nailgun.errors import errors
class PluginValidator(BasicValidator):
@classmethod
def validate_delete(cls, instance):
if instance.clusters:
raise errors.CannotDelete(
"Can't delete plugin which is enabled"
"for some environment."
)
@classmethod
def validate_update(cls, data, instance):
"""It should be possible to update plugin info."""
return cls.validate(data)

View File

@ -175,6 +175,11 @@ PROVISION_METHODS = Enum(
'image'
)
STAGES = Enum(
'pre_deployment',
'post_deployment'
)
ACTION_TYPES = Enum(
'http_request',
'nailgun_task'

View File

@ -130,6 +130,28 @@ def upgrade_schema():
default={}
),
sa.PrimaryKeyConstraint('id'))
op.create_table(
'plugins',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('version', sa.String(length=32), nullable=False),
sa.Column('description', sa.String(length=400), nullable=True),
sa.Column('releases', JSON(), nullable=True),
sa.Column('types', JSON(), nullable=True),
sa.Column('package_version', sa.String(length=32), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name', 'version', name='_name_version_unique')
)
op.create_table(
'cluster_plugins',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('plugin_id', sa.Integer(), nullable=False),
sa.Column('cluster_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['cluster_id'], ['clusters.id'], ),
sa.ForeignKeyConstraint(
['plugin_id'], ['plugins.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
def upgrade_releases():
@ -180,6 +202,8 @@ def downgrade_schema():
op.drop_table('action_logs')
op.drop_table('master_node_settings')
map(drop_enum, ENUMS)
op.drop_table('cluster_plugins')
op.drop_table('plugins')
def downgrade_data():

View File

@ -0,0 +1,52 @@
# Copyright 2014 Mirantis, 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.
from sqlalchemy import Column
from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import UniqueConstraint
from sqlalchemy.orm import relationship
from nailgun.db.sqlalchemy.models.base import Base
from nailgun.db.sqlalchemy.models.fields import JSON
class ClusterPlugins(Base):
__tablename__ = 'cluster_plugins'
id = Column(Integer, primary_key=True)
plugin_id = Column(Integer, ForeignKey('plugins.id', ondelete='CASCADE'),
nullable=False)
cluster_id = Column(Integer, ForeignKey('clusters.id'))
class Plugin(Base):
__tablename__ = 'plugins'
__table_args__ = (
UniqueConstraint('name', 'version', name='_name_version_unique'),)
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
version = Column(String(32), nullable=False)
description = Column(String(400))
releases = Column(JSON, default=[])
types = Column(JSON, default=[])
package_version = Column(String(32), nullable=False)
clusters = relationship("Cluster",
secondary=ClusterPlugins.__table__,
backref="plugins")

View File

@ -48,6 +48,8 @@ default_messages = {
"TaskAlreadyRunning": "A task is already running",
"InvalidReleaseId": "Release Id is invalid",
"UnsupportedSerializer": "There are no serializers for a given cluster",
"InvalidOperatingSystem": "Invalid operating system",
"CannotFindPluginForRelease": "Cannot find plugin for the release",
# disk errors
"NotEnoughFreeSpace": "Not enough free space",

View File

@ -40,3 +40,6 @@ from nailgun.objects.node import NodeCollection
from nailgun.objects.capacity import CapacityLog
from nailgun.objects.master_node_settings import MasterNodeSettings
from nailgun.objects.plugin import Plugin
from nailgun.objects.plugin import PluginCollection

View File

@ -33,6 +33,8 @@ from nailgun.logger import logger
from nailgun.objects import NailgunCollection
from nailgun.objects import NailgunObject
from nailgun.plugins.manager import PluginManager
from nailgun.settings import settings
from nailgun.utils import AttributesGenerator
@ -224,6 +226,12 @@ class Cluster(NailgunObject):
}
)
Attributes.generate_fields(attributes)
# when attributes created we need to understand whether should plugin
# be applied for created cluster
plugin_attrs = PluginManager.get_plugin_attributes(instance)
editable = dict(plugin_attrs, **instance.attributes.editable)
instance.attributes.editable = editable
db().flush()
@classmethod
def get_attributes(cls, instance):
@ -236,6 +244,29 @@ class Cluster(NailgunObject):
models.Attributes.cluster_id == instance.id
).first()
@classmethod
def update_attributes(cls, instance, data):
PluginManager.process_cluster_attributes(instance, data['editable'])
for key, value in data.iteritems():
setattr(instance.attributes, key, value)
cls.add_pending_changes(instance, "attributes")
db().flush()
@classmethod
def patch_attributes(cls, instance, data):
PluginManager.process_cluster_attributes(instance, data['editable'])
instance.attributes.editable = dict_merge(
instance.attributes.editable, data['editable'])
cls.add_pending_changes(instance, "attributes")
db().flush()
@classmethod
def get_editable_attributes(cls, instance):
attrs = cls.get_attributes(instance)
editable = attrs.editable
return {'editable': editable}
@classmethod
def get_network_manager(cls, instance=None):
"""Get network manager for Cluster instance.

View File

@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# Copyright 2014 Mirantis, 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.
from nailgun.db.sqlalchemy.models import plugins as plugin_db_model
from nailgun.objects import base
from nailgun.objects.serializers import plugin
from nailgun.db import db
class Plugin(base.NailgunObject):
model = plugin_db_model.Plugin
serializer = plugin.PluginSerializer
@classmethod
def get_by_name_version(cls, name, version):
return db().query(cls.model).\
filter_by(name=name, version=version).first()
class PluginCollection(base.NailgunCollection):
single = Plugin

View File

@ -93,10 +93,12 @@ class ReleaseOrchestratorData(NailgunObject):
cls.render_path(value, context)
rendered_data['puppet_manifests_source'] = \
cls.render_path(rendered_data['puppet_manifests_source'], context)
cls.render_path(rendered_data.get(
'puppet_manifests_source', 'default'), context)
rendered_data['puppet_modules_source'] = \
cls.render_path(rendered_data['puppet_modules_source'], context)
cls.render_path(rendered_data.get(
'puppet_modules_source', 'default'), context)
return rendered_data

View File

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Copyright 2014 Mirantis, 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.
from nailgun.objects.serializers.base import BasicSerializer
class PluginSerializer(BasicSerializer):
fields = (
"id",
"name",
"version",
"description",
"releases",
"types",
"package_version"
)

View File

@ -30,7 +30,8 @@ class ReleaseSerializer(BasicSerializer):
"roles",
"roles_metadata",
"wizard_metadata",
"state"
"state",
"attributes_metadata"
)
@classmethod

View File

@ -0,0 +1,224 @@
# -*- coding: utf-8 -*-
# Copyright 2014 Mirantis, 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.
"""Serializer for plugins tasks"""
from nailgun import consts
from nailgun.errors import errors
from nailgun.logger import logger
from nailgun.orchestrator.priority_serializers import PriorityStrategy
from nailgun.plugins.manager import PluginManager
def make_repo_task(uids, repo_data, repo_path):
return {
'type': 'upload_file',
'uids': uids,
'parameters': {
'path': repo_path,
'data': repo_data}}
def make_ubuntu_repo_task(plugin_name, repo_url, uids):
repo_data = ['deb {0}'.format(repo_url)]
repo_path = '/etc/apt/sources.list.d/{0}.list'.format(plugin_name)
return make_repo_task(uids, repo_data, repo_path)
def make_centos_repo_task(plugin_name, repo_url, uids):
repo_data = '\n'.join([
'[{0}]',
'name=Plugin {0} repository',
'baseurl={1}',
'gpgcheck=0']).format(plugin_name, repo_url)
repo_path = '/etc/yum.repos.d/{0}.repo'.format(plugin_name)
return make_repo_task(uids, repo_data, repo_path)
def make_sync_scripts_task(uids, src, dst):
return {
'type': 'sync',
'uids': uids,
'parameters': {
'src': src,
'dst': dst}}
def make_shell_task(uids, task, cwd):
return {
'type': 'shell',
'uids': uids,
'parameters': {
'cmd': task['parameters']['cmd'],
'timeout': task['parameters']['timeout'],
'cwd': cwd}}
def make_puppet_task(uids, task, cwd):
return {
'type': 'puppet',
'uids': uids,
'parameters': {
'puppet_manifest': task['parameters']['puppet_manifest'],
'puppet_modules': task['parameters']['puppet_modules'],
'timeout': task['parameters']['timeout'],
'cwd': cwd}}
class BasePluginDeploymentHooksSerializer(object):
def __init__(self, cluster, nodes):
self.cluster = cluster
self.nodes = nodes
self.priority = PriorityStrategy()
def deployment_tasks(self, plugins, stage):
tasks = []
for plugin in plugins:
puppet_tasks = filter(
lambda t: (t['type'] == 'puppet' and
t['stage'] == stage),
plugin.tasks)
shell_tasks = filter(
lambda t: (t['type'] == 'shell' and
t['stage'] == stage),
plugin.tasks)
for task in shell_tasks:
uids = self.get_uids_for_task(task)
if not uids:
continue
tasks.append(make_shell_task(
uids,
task,
plugin.slaves_scripts_path))
for task in puppet_tasks:
uids = self.get_uids_for_task(task)
if not uids:
continue
tasks.append(make_puppet_task(
uids,
task,
plugin.slaves_scripts_path))
return tasks
def get_uids_for_tasks(self, tasks):
uids = []
for task in tasks:
if isinstance(task['role'], list):
for node in self.nodes:
required_for_node = set(task['role']) & set(node.all_roles)
if required_for_node:
uids.append(node.id)
elif task['role'] == '*':
uids.extend([n.id for n in self.nodes])
else:
logger.warn(
'Wrong task format, `role` should be a list or "*": %s',
task)
return list(set(uids))
def get_uids_for_task(self, task):
return self.get_uids_for_tasks([task])
class PluginsPreDeploymentHooksSerializer(BasePluginDeploymentHooksSerializer):
def serialize(self):
tasks = []
plugins = PluginManager.get_cluster_plugins_with_tasks(self.cluster)
tasks.extend(self.create_repositories(plugins))
tasks.extend(self.sync_scripts(plugins))
tasks.extend(self.deployment_tasks(plugins))
self.priority.one_by_one(tasks)
return tasks
def create_repositories(self, plugins):
operating_system = self.cluster.release.operating_system
if operating_system == 'CentOS':
make_repo = make_centos_repo_task
elif operating_system == 'Ubuntu':
make_repo = make_ubuntu_repo_task
else:
raise errors.InvalidOperatingSystem(
'Operating system {0} is invalid'.format(operating_system))
repo_tasks = []
for plugin in plugins:
uids = self.get_uids_for_tasks(plugin.tasks)
# If there are not nodes for tasks execution
# or if there are no files in repository
if not uids or not plugin.repo_files(self.cluster):
continue
repo_tasks.append(
make_repo(
plugin.full_name, plugin.repo_url(self.cluster), uids))
return repo_tasks
def sync_scripts(self, plugins):
tasks = []
for plugin in plugins:
uids = self.get_uids_for_tasks(plugin.tasks)
if not uids:
continue
tasks.append(make_sync_scripts_task(
uids,
plugin.master_scripts_path(self.cluster),
plugin.slaves_scripts_path))
return tasks
def deployment_tasks(self, plugins):
return super(
PluginsPreDeploymentHooksSerializer, self).\
deployment_tasks(plugins, consts.STAGES.pre_deployment)
class PluginsPostDeploymentHooksSerializer(
BasePluginDeploymentHooksSerializer):
def serialize(self):
tasks = []
plugins = PluginManager.get_cluster_plugins_with_tasks(self.cluster)
tasks.extend(self.deployment_tasks(plugins))
self.priority.one_by_one(tasks)
return tasks
def deployment_tasks(self, plugins):
return super(
PluginsPostDeploymentHooksSerializer, self).\
deployment_tasks(plugins, consts.STAGES.post_deployment)
def pre_deployment_serialize(cluster, nodes):
serializer = PluginsPreDeploymentHooksSerializer(cluster, nodes)
return serializer.serialize()
def post_deployment_serialize(cluster, nodes):
serializer = PluginsPostDeploymentHooksSerializer(cluster, nodes)
return serializer.serialize()

View File

@ -45,30 +45,30 @@ class Priority(object):
class PriorityStrategy(object):
"""Set priorities for sequence of nodes using some strategy.
"""Set priorities for sequence of tasks using some strategy.
"""
def __init__(self):
#: priority sequence generator
self._priority = Priority()
def one_by_one(self, nodes):
"""Deploy given nodes one by one."""
for n in nodes:
n['priority'] = self._priority.next()
def one_by_one(self, tasks):
"""Deploy given tasks one by one."""
for t in tasks:
t['priority'] = self._priority.next()
def in_parallel(self, nodes):
"""Deploy given nodes in parallel mode."""
def in_parallel(self, tasks):
"""Deploy given tasks in parallel mode."""
self._priority.next()
for n in nodes:
n['priority'] = self._priority.current
for t in tasks:
t['priority'] = self._priority.current
def in_parallel_by(self, nodes, amount):
def in_parallel_by(self, tasks, amount):
"""Deploy given nodes in parallel by chunks."""
for index, node in enumerate(nodes):
for index, task in enumerate(tasks):
if index % amount == 0:
self._priority.next()
node['priority'] = self._priority.current
task['priority'] = self._priority.current
@six.add_metaclass(abc.ABCMeta)

View File

@ -0,0 +1,13 @@
# Copyright 2014 Mirantis, 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.

View File

@ -0,0 +1,169 @@
# Copyright 2014 Mirantis, 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 glob
import os
from urlparse import urljoin
import yaml
from nailgun.logger import logger
from nailgun.settings import settings
class ClusterAttributesPlugin(object):
"""Implements wrapper for plugin db model to provide
logic related to configuration files.
1. Uploading plugin provided cluster attributes
2. Uploading tasks
3. Enabling/Disabling of plugin based on cluster attributes
4. Providing repositories/deployment scripts related info to clients
"""
environment_config_name = 'environment_config.yaml'
task_config_name = 'tasks.yaml'
def __init__(self, plugin):
self.plugin = plugin
self.plugin_path = os.path.join(
settings.PLUGINS_PATH,
self.full_name)
self.config_file = os.path.join(
self.plugin_path,
self.environment_config_name)
self.tasks = []
def _load_config(self, config):
if os.access(config, os.R_OK):
with open(config, "r") as conf:
return yaml.load(conf.read())
else:
logger.warning("Config {0} is not readable.".format(config))
def get_plugin_attributes(self, cluster):
"""Should be used for initial configuration uploading to
custom storage. Will be invoked in 2 cases:
1. Cluster is created but there was no plugins in system
on that time, so when plugin is uploaded we need to iterate
over all clusters and decide if plugin should be applied
2. Plugins is uploaded before cluster creation, in this case
we will iterate over all plugins and upload configuration for them
In this case attributes will be added to same cluster attributes
model and stored in editable field
"""
config = {}
if os.path.exists(self.config_file):
config = self._load_config(self.config_file)
if cluster.release.version in self.plugin_release_versions:
attrs = config.get("attributes", {})
attrs.update(self.metadata)
return {self.plugin.name: attrs}
return {}
def process_cluster_attributes(self, cluster, cluster_attrs):
"""Checks cluster attributes for plugin related metadata.
Then enable or disable plugin for cluster based on metadata
enabled field.
"""
custom_attrs = cluster_attrs.get(self.plugin.name, {})
if custom_attrs:
enable = custom_attrs['metadata']['enabled']
# value is true and plugin is not enabled for this cluster
# that means plugin was enabled on this request
if enable and cluster not in self.plugin.clusters:
self.plugin.clusters.append(cluster)
# value is false and plugin is enabled for this cluster
# that means plugin was disabled on this request
elif not enable and cluster in self.plugin.clusters:
self.plugin.clusters.remove(cluster)
@property
def metadata(self):
return {u'metadata': {u'enabled': False, u'toggleable': True,
u'weight': 70, u'label': self.plugin.name}}
def set_cluster_tasks(self, cluster):
"""Loads plugins provided tasks from tasks config file and
sets them to instance tasks variable.
"""
task_yaml = os.path.join(
self.plugin_path,
self.task_config_name)
if os.path.exists(task_yaml):
self.tasks = self._load_config(task_yaml)
def filter_tasks(self, tasks, stage):
filtered = []
for task in tasks:
if stage and stage == task.get('stage'):
filtered.append(task)
return filtered
@property
def plugin_release_versions(self):
if not self.plugin.releases:
return set()
return set([rel['version'] for rel in self.plugin.releases])
@property
def full_name(self):
return u'{0}-{1}'.format(self.plugin.name, self.plugin.version)
def get_release_info(self, release):
"""Returns plugin release information which corresponds to
a provided release.
"""
os = release.operating_system.lower()
version = release.version
release_info = filter(
lambda r: (r['os'] == os and
r['version'] == version),
self.plugin.releases)
return release_info[0]
@property
def slaves_scripts_path(self):
return settings.PLUGINS_SLAVES_SCRIPTS_PATH.format(
plugin_name=self.full_name)
def repo_files(self, cluster):
release_info = self.get_release_info(cluster.release)
repo_path = os.path.join(
settings.PLUGINS_PATH,
self.full_name,
release_info['repository_path'],
'*')
return glob.glob(repo_path)
def repo_url(self, cluster):
release_info = self.get_release_info(cluster.release)
repo_base = settings.PLUGINS_REPO_URL.format(
master_ip=settings.MASTER_IP,
plugin_name=self.full_name)
return urljoin(repo_base, release_info['repository_path'])
def master_scripts_path(self, cluster):
release_info = self.get_release_info(cluster.release)
# NOTE(eli): we cannot user urljoin here, because it
# works wrong in case, if protocol is rsync
base_url = settings.PLUGINS_SLAVES_RSYNC.format(
master_ip=settings.MASTER_IP,
plugin_name=self.full_name)
return '{0}{1}'.format(
base_url,
release_info['deployment_scripts_path'])

View File

@ -0,0 +1,46 @@
# Copyright 2014 Mirantis, 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.
from nailgun.objects.plugin import PluginCollection
from nailgun.plugins.attr_plugin import ClusterAttributesPlugin
class PluginManager(object):
@classmethod
def process_cluster_attributes(cls, cluster, attrs, query=None):
if query is None:
query = PluginCollection.all()
for plugin_db in query:
attr_plugin = ClusterAttributesPlugin(plugin_db)
attr_plugin.process_cluster_attributes(cluster, attrs)
@classmethod
def get_plugin_attributes(cls, cluster):
plugins_attrs = {}
for plugin_db in PluginCollection.all():
attr_plugin = ClusterAttributesPlugin(plugin_db)
attrs = attr_plugin.get_plugin_attributes(cluster)
plugins_attrs.update(attrs)
return plugins_attrs
@classmethod
def get_cluster_plugins_with_tasks(cls, cluster):
attr_plugins = []
for plugin_db in cluster.plugins:
attr_pl = ClusterAttributesPlugin(plugin_db)
attr_pl.set_cluster_tasks(cluster)
attr_plugins.append(attr_pl)
return attr_plugins

View File

@ -67,6 +67,11 @@ DEFAULT_REPO:
centos: "http://{MASTER_IP}:8080/centos/x86_64"
ubuntu: "http://{MASTER_IP}:8080/ubuntu/x86_64 precise main"
PLUGINS_PATH: '/var/www/nailgun/plugins'
PLUGINS_SLAVES_SCRIPTS_PATH: '/etc/fuel/plugins/{plugin_name}/'
PLUGINS_REPO_URL: 'http://{master_ip}:8080/plugins/{plugin_name}/'
PLUGINS_SLAVES_RSYNC: 'rsync://{master_ip}:/plugins/{plugin_name}/'
APP_LOG: &nailgun_log "/var/log/nailgun/app.log"
API_LOG: &api_log "/var/log/nailgun/api.log"
SYSLOG_DIR: &remote_syslog_dir "/var/log/remote/"

View File

@ -38,6 +38,7 @@ from nailgun.errors import errors
from nailgun.logger import logger
from nailgun.network.checker import NetworkCheck
from nailgun.orchestrator import deployment_serializers
from nailgun.orchestrator import plugins_serializers
from nailgun.orchestrator import provisioning_serializers
from nailgun.settings import settings
from nailgun.task.fake import FAKE_THREADS
@ -135,9 +136,12 @@ class DeploymentTask(object):
db().add(n)
db().flush()
# here we replace deployment data if user redefined them
serialized_cluster = deployment_serializers.serialize(
task.cluster, nodes)
pre_deployment = plugins_serializers.pre_deployment_serialize(
task.cluster, nodes)
post_deployment = plugins_serializers.post_deployment_serialize(
task.cluster, nodes)
# After serialization set pending_addition to False
for node in nodes:
@ -149,7 +153,9 @@ class DeploymentTask(object):
'deploy_resp',
{
'task_uuid': task.uuid,
'deployment_info': serialized_cluster
'deployment_info': serialized_cluster,
'pre_deployment': pre_deployment,
'post_deployment': post_deployment
}
)

View File

@ -246,6 +246,8 @@ class TestHandlers(BaseIntegrationTest):
deployment_msg['args']['task_uuid'] = deploy_task_uuid
deployment_msg['args']['deployment_info'] = deployment_info
deployment_msg['args']['pre_deployment'] = []
deployment_msg['args']['post_deployment'] = []
provision_nodes = []
admin_net = self.env.network_manager.get_admin_network_group()
@ -680,6 +682,8 @@ class TestHandlers(BaseIntegrationTest):
deployment_msg['args']['task_uuid'] = deploy_task_uuid
deployment_msg['args']['deployment_info'] = deployment_info
deployment_msg['args']['pre_deployment'] = []
deployment_msg['args']['post_deployment'] = []
provision_nodes = []
admin_net = self.env.network_manager.get_admin_network_group()

View File

@ -0,0 +1,217 @@
# -*- coding: utf-8 -*-
# Copyright 2014 Mirantis, 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 mock
import yaml
from nailgun import objects
from nailgun.openstack.common import jsonutils
from nailgun.test import base
SAMPLE_PLUGIN = {
'version': '1.1.0',
'name': 'testing',
'package_version': '1',
'description': 'Enable to use plugin X for Neutron',
'types': ['nailgun', 'repository', 'deployment_scripts'],
'fuel_version': 6.0,
'releases': [
{'repository_path': 'repositories/ubuntu',
'version': '2014.2-6.0', 'os': 'ubuntu',
'mode': ['ha', 'multinode'],
'deployment_scripts_path': 'deployment_scripts/'},
{'repository_path': 'repositories/centos',
'version': '2014.2-6.0', 'os': 'centos',
'mode': ['ha', 'multinode'],
'deployment_scripts_path': 'deployment_scripts/'}]}
ENVIRONMENT_CONFIG = {
'attributes': {
'lbaas_simple_text': {
'value': 'Set default value',
'type': 'text',
'description': 'Description for text field',
'weight': 25,
'label': 'Text field'}}}
TASKS_CONFIG = [
{'priority': 10,
'role': ['controller'],
'type': 'shell',
'parameters': {'cmd': './lbaas_enable.sh', 'timeout': 42},
'stage': 'post_deployment'},
{'priority': 10,
'role': '*',
'type': 'shell',
'parameters': {'cmd': 'echo all > /tmp/plugin.all', 'timeout': 42},
'stage': 'pre_deployment'}]
def get_config(config):
def _get_config(*args):
return mock.mock_open(read_data=yaml.dump(config))()
return _get_config
class BasePluginTest(base.BaseIntegrationTest):
def create_plugin(self):
resp = self.app.post(
base.reverse('PluginCollectionHandler'),
jsonutils.dumps(SAMPLE_PLUGIN),
headers=self.default_headers
)
return resp
def delete_plugin(self, plugin_id):
resp = self.app.delete(
base.reverse('PluginHandler', {'obj_id': plugin_id}),
headers=self.default_headers
)
return resp
def create_cluster(self, nodes=None):
nodes = nodes if nodes else []
with mock.patch('nailgun.plugins.attr_plugin.os') as os:
with mock.patch('nailgun.plugins.attr_plugin.open',
create=True) as f_m:
os.access.return_value = True
os.path.exists.return_value = True
f_m.side_effect = get_config(ENVIRONMENT_CONFIG)
self.env.create(
release_kwargs={'version': '2014.2-6.0',
'operating_system': 'Ubuntu'},
nodes_kwargs=nodes)
return self.env.clusters[0]
def modify_plugin(self, cluster, plugin_name, enabled):
editable_attrs = cluster.attributes.editable
editable_attrs[plugin_name]['metadata']['enabled'] = enabled
resp = self.app.put(
base.reverse('ClusterAttributesHandler',
{'cluster_id': cluster.id}),
jsonutils.dumps({'editable': editable_attrs}),
headers=self.default_headers)
return resp
def enable_plugin(self, cluster, plugin_name):
return self.modify_plugin(cluster, plugin_name, True)
def disable_plugin(self, cluster, plugin_name):
return self.modify_plugin(cluster, plugin_name, False)
def get_pre_hooks(self, cluster):
with mock.patch('nailgun.plugins.attr_plugin.glob') as glob:
glob.glob.return_value = ['/some/path']
with mock.patch('nailgun.plugins.attr_plugin.os') as os:
with mock.patch('nailgun.plugins.attr_plugin.open',
create=True) as f_m:
os.access.return_value = True
os.path.exists.return_value = True
f_m.side_effect = get_config(TASKS_CONFIG)
resp = self.app.get(
base.reverse('DefaultPrePluginsHooksInfo',
{'cluster_id': cluster.id}),
headers=self.default_headers)
return resp
def get_post_hooks(self, cluster):
with mock.patch('nailgun.plugins.attr_plugin.os') as os:
with mock.patch('nailgun.plugins.attr_plugin.open',
create=True) as f_m:
os.access.return_value = True
os.path.exists.return_value = True
f_m.side_effect = get_config(TASKS_CONFIG)
resp = self.app.get(
base.reverse('DefaultPostPluginsHooksInfo',
{'cluster_id': cluster.id}),
headers=self.default_headers)
return resp
class TestPluginsApi(BasePluginTest):
def test_plugin_created_on_post(self):
resp = self.create_plugin()
self.assertEqual(resp.status_code, 201)
def test_env_create_and_load_env_config(self):
self.create_plugin()
cluster = self.create_cluster()
self.assertIn(SAMPLE_PLUGIN['name'], cluster.attributes.editable)
def test_enable_disable_plugin(self):
resp = self.create_plugin()
plugin = objects.Plugin.get_by_uid(resp.json['id'])
cluster = self.create_cluster()
self.assertEqual(plugin.clusters, [])
resp = self.enable_plugin(cluster, plugin.name)
self.assertEqual(resp.status_code, 200)
self.assertIn(cluster, plugin.clusters)
resp = self.disable_plugin(cluster, plugin.name)
self.assertEqual(resp.status_code, 200)
self.assertEqual(plugin.clusters, [])
def test_delete_plugin(self):
resp = self.create_plugin()
del_resp = self.delete_plugin(resp.json['id'])
self.assertEqual(del_resp.status_code, 204)
def test_update_plugin(self):
resp = self.create_plugin()
data = resp.json
data['package_version'] = 2
plugin_id = data.pop('id')
resp = self.app.put(
base.reverse('PluginHandler', {'obj_id': plugin_id}),
jsonutils.dumps(data),
headers=self.default_headers
)
self.assertEqual(resp.status_code, 200)
updated_data = resp.json
updated_data.pop('id')
self.assertEqual(updated_data, data)
class TestPrePostHooks(BasePluginTest):
def setUp(self):
super(TestPrePostHooks, self).setUp()
self.create_plugin()
self.cluster = self.create_cluster([
{'roles': ['controller'], 'pending_addition': True},
{'roles': ['compute'], 'pending_addition': True}])
self.enable_plugin(self.cluster, SAMPLE_PLUGIN['name'])
def test_generate_pre_hooks(self):
tasks = self.get_pre_hooks(self.cluster).json
upload_file = [t for t in tasks if t['type'] == 'upload_file']
rsync = [t for t in tasks if t['type'] == 'sync']
self.assertEqual(len(upload_file), 1)
self.assertEqual(len(rsync), 1)
for t in tasks:
#shoud uid be a string
self.assertEqual(
sorted(t['uids']), sorted([n.id for n in self.cluster.nodes]))
def test_generate_post_hooks(self):
tasks = self.get_post_hooks(self.cluster).json
self.assertEqual(len(tasks), 1)
controller_id = [n.id for n in self.cluster.nodes
if 'controller' in n.roles]
self.assertEqual(controller_id, tasks[0]['uids'])

View File

@ -0,0 +1,140 @@
# Copyright 2014 Mirantis, 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
import mock
import yaml
from nailgun.db import db
from nailgun.objects import Plugin
from nailgun.plugins import attr_plugin
from nailgun.settings import settings
from nailgun.test import base
SAMPLE_PLUGIN = {
'version': '0.1.0',
'name': 'testing_plugin',
'package_version': '1',
'description': 'Enable to use plugin X for Neutron',
'types': ['nailgun', 'repository', 'deployment_scripts'],
'fuel_version': 6.0,
'releases': [
{'repository_path': 'repositories/ubuntu',
'version': '2014.2-6.0', 'os': 'ubuntu',
'mode': ['ha', 'multinode'],
'deployment_scripts_path': 'deployment_scripts/'},
{'repository_path': 'repositories/centos',
'version': '2014.2-6.0', 'os': 'centos',
'mode': ['ha', 'multinode'],
'deployment_scripts_path': 'deployment_scripts/'}]}
ENVIRONMENT_CONFIG = {
'attributes': {
'lbaas_simple_text': {
'value': 'Set default value',
'type': 'text',
'description': 'Description for text field',
'weight': 25,
'label': 'Text field'}}}
def get_config(*args):
return mock.mock_open(read_data=yaml.dump(ENVIRONMENT_CONFIG))()
class TestPlugin(base.BaseTestCase):
def setUp(self):
super(TestPlugin, self).setUp()
self.plugin = Plugin.create(SAMPLE_PLUGIN)
self.env.create(
release_kwargs={'version': '2014.2-6.0',
'operating_system': 'Ubuntu'})
self.cluster = self.env.clusters[0]
self.attr_plugin = attr_plugin.ClusterAttributesPlugin(self.plugin)
db().flush()
@mock.patch('nailgun.plugins.attr_plugin.open', create=True)
@mock.patch('nailgun.plugins.attr_plugin.os.access')
@mock.patch('nailgun.plugins.attr_plugin.os.path.exists')
def test_get_plugin_attributes(self, mexists, maccess, mopen):
"""Should load attributes from environment_config.
Attributes should contain provided attributes by plugin and
also generated metadata
"""
maccess.return_value = True
mexists.return_value = True
mopen.side_effect = get_config
attributes = self.attr_plugin.get_plugin_attributes(
self.cluster)
self.assertEqual(
attributes['testing_plugin']['lbaas_simple_text'],
ENVIRONMENT_CONFIG['attributes']['lbaas_simple_text'])
self.assertEqual(
attributes['testing_plugin']['metadata'],
self.attr_plugin.metadata['metadata'])
def test_plugin_release_versions(self):
"""Helper should return set of all release versions this plugin
is applicable to.
"""
self.assertEqual(
self.attr_plugin.plugin_release_versions, set(['2014.2-6.0']))
def test_full_name(self):
"""Plugin full name should be made from name and version."""
self.assertEqual(
self.attr_plugin.full_name,
'{0}-{1}'.format(self.plugin.name, self.plugin.version))
def test_get_release_info(self):
"""Should return 1st plugin release info which matches
provided release.
"""
release = self.attr_plugin.get_release_info(self.cluster.release)
self.assertEqual(release, SAMPLE_PLUGIN['releases'][0])
def test_slaves_scripts_path(self):
expected = settings.PLUGINS_SLAVES_SCRIPTS_PATH.format(
plugin_name=self.attr_plugin.full_name)
self.assertEqual(expected, self.attr_plugin.slaves_scripts_path)
@mock.patch('nailgun.plugins.attr_plugin.glob')
def test_repo_files(self, glob_mock):
self.attr_plugin.repo_files(self.cluster)
expected_call = os.path.join(
settings.PLUGINS_PATH,
self.attr_plugin.full_name,
'repositories/ubuntu',
'*')
glob_mock.glob.assert_called_once_with(expected_call)
@mock.patch('nailgun.plugins.attr_plugin.urljoin')
def test_repo_url(self, murljoin):
self.attr_plugin.repo_url(self.cluster)
repo_base = settings.PLUGINS_REPO_URL.format(
master_ip=settings.MASTER_IP,
plugin_name=self.attr_plugin.full_name)
murljoin.assert_called_once_with(repo_base, 'repositories/ubuntu')
def test_master_scripts_path(self):
base_url = settings.PLUGINS_SLAVES_RSYNC.format(
master_ip=settings.MASTER_IP,
plugin_name=self.attr_plugin.full_name)
expected = '{0}{1}'.format(base_url, 'deployment_scripts/')
self.assertEqual(
expected, self.attr_plugin.master_scripts_path(self.cluster))

View File

@ -111,8 +111,8 @@ class TestHandlers(BaseIntegrationTest):
'5.1': 'http://{MASTER_IP}:8080/centos/x86_64',
'5.1-user': 'http://{MASTER_IP}:8080/centos-user/x86_64',
},
'puppet_manifests_source': 'rsync://{MASTER_IP}:/puppet/modules/',
'puppet_modules_source': 'rsync://{MASTER_IP}:/puppet/manifests/',
'puppet_modules_source': 'rsync://{MASTER_IP}:/puppet/modules/',
'puppet_manifests_source': 'rsync://{MASTER_IP}:/puppet/manifests/'
}
resp = self.app.put(
@ -131,8 +131,8 @@ class TestHandlers(BaseIntegrationTest):
'5.1': 'http://127.0.0.1:8080/centos/x86_64',
'5.1-user': 'http://127.0.0.1:8080/centos-user/x86_64'})
self.assertEqual(
orchestrator_data['puppet_manifests_source'],
orchestrator_data['puppet_modules_source'],
'rsync://127.0.0.1:/puppet/modules/')
self.assertEqual(
orchestrator_data['puppet_modules_source'],
orchestrator_data['puppet_manifests_source'],
'rsync://127.0.0.1:/puppet/manifests/')

View File

@ -4,7 +4,7 @@ Mako==0.9.1
MarkupSafe==0.18
Paste==1.7.5.1
PyYAML==3.10
SQLAlchemy>=0.7.9,<=0.9.99
SQLAlchemy>=0.9.4
Shotgun==0.1.0
alembic>=0.6.2
amqplib==1.0.2