Add extensions API handler for cluster

The patch includes new handlers for extensions management for
clusters, unit and integration tests.

Co-Authored-By: Sylwester Brzeczkowski <sbrzeczkowski@mirantis.com>

Change-Id: I4b81ec5b4ae6986068832bd28c08831881feeee6
Blueprint: extensions-management
Closes-Bug: #1614526
This commit is contained in:
Sylwester Brzeczkowski 2016-01-07 11:10:38 +01:00 committed by Alexander Gordeev
parent 86e5612e5b
commit 5a6bd3f490
11 changed files with 295 additions and 22 deletions

View File

@ -194,7 +194,8 @@ class BaseHandler(object):
errors.UnavailableRelease,
errors.CannotCreate,
errors.CannotUpdate,
errors.CannotDelete
errors.CannotDelete,
errors.CannotFindExtension,
) as exc:
raise cls.http(400, exc.message)
except (

View File

@ -40,7 +40,11 @@ from nailgun.api.v1.validators.cluster import ClusterStopDeploymentValidator
from nailgun.api.v1.validators.cluster import ClusterValidator
from nailgun.api.v1.validators.cluster import VmwareAttributesValidator
from nailgun.api.v1.validators.extension import ExtensionValidator
from nailgun import errors
from nailgun.extensions import set_extensions_for_object
from nailgun.logger import logger
from nailgun import objects
from nailgun import utils
@ -505,3 +509,45 @@ class ClusterDeploymentGraphCollectionHandler(
"""Cluster Handler for deployment graphs configuration."""
related = objects.Cluster
class ClusterExtensionsHandler(BaseHandler):
"""Cluster extensions handler"""
validator = ExtensionValidator
def _get_cluster_obj(self, cluster_id):
return self.get_object_or_404(
objects.Cluster, cluster_id,
log_404=(
"error",
"There is no cluster with id '{0}' in DB.".format(cluster_id)
)
)
@handle_errors
@validate
@serialize
def GET(self, cluster_id):
""":returns: JSONized list of enabled Cluster extensions
:http: * 200 (OK)
* 404 (cluster not found in db)
"""
cluster = self._get_cluster_obj(cluster_id)
return cluster.extensions
@handle_errors
@validate
@serialize
def PUT(self, cluster_id):
""":returns: JSONized list of enabled Cluster extensions
:http: * 200 (OK)
* 400 (there is no such extension available)
* 404 (cluster not found in db)
"""
cluster = self._get_cluster_obj(cluster_id)
data = self.checked_data()
set_extensions_for_object(cluster, data)
return cluster.extensions

View File

@ -34,6 +34,7 @@ from nailgun.api.v1.handlers.cluster import \
ClusterDeploymentGraphCollectionHandler
from nailgun.api.v1.handlers.cluster import ClusterDeploymentGraphHandler
from nailgun.api.v1.handlers.cluster import ClusterDeploymentTasksHandler
from nailgun.api.v1.handlers.cluster import ClusterExtensionsHandler
from nailgun.api.v1.handlers.cluster import ClusterGeneratedData
from nailgun.api.v1.handlers.cluster import ClusterHandler
from nailgun.api.v1.handlers.cluster import \
@ -251,6 +252,9 @@ urls = (
r'/clusters/(?P<cluster_id>\d+)/plugin_links/(?P<obj_id>\d+)/?$',
ClusterPluginLinkHandler,
r'/clusters/(?P<cluster_id>\d+)/extensions/?$',
ClusterExtensionsHandler,
r'/nodegroups/?$',
NodeGroupCollectionHandler,
r'/nodegroups/(?P<obj_id>\d+)/?$',

View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Copyright 2016 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.api.v1.validators.json_schema import extension as extension_schema
from nailgun import errors
from nailgun.extensions import get_all_extensions
class ExtensionValidator(BasicValidator):
single_scheme = extension_schema.single_schema
collection_schema = extension_schema.collection_schema
@classmethod
def validate(cls, data):
data = set(super(ExtensionValidator, cls).validate(data))
all_extensions = set(ext.name for ext in get_all_extensions())
not_found_extensions = data - all_extensions
if not_found_extensions:
raise errors.CannotFindExtension(
"No such extensions: {0}".format(
", ".join(sorted(not_found_extensions))))
return data

View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Copyright 2016 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.json_schema import base_types
single_schema = {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Extension",
"description": "Serialized Nailgun extension information",
"type": "string",
}
collection_schema = {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Extension collection",
"description": "Serialized Nailgun extension information",
"type": base_types.STRINGS_ARRAY,
}

View File

@ -45,4 +45,5 @@ from nailgun.extensions.manager import \
from nailgun.extensions.manager import \
fire_callback_on_node_serialization_for_provisioning
from nailgun.extensions.manager import node_extension_call
from nailgun.extensions.manager import set_extensions_for_object
from nailgun.extensions.manager import setup_yaql_context

View File

@ -19,6 +19,7 @@ from itertools import chain
from stevedore.extension import ExtensionManager
from nailgun.db import db
from nailgun import errors
from nailgun.extensions import consts
@ -60,6 +61,11 @@ def get_extension(name):
"Cannot find extension with name '{0}'".format(name))
def set_extensions_for_object(obj, extensions_names):
obj.extensions = extensions_names
db().flush()
def callback_wrapper(name, pass_args=None):
pass_args = pass_args or []
@ -167,13 +173,13 @@ def fire_callback_on_cluster_delete(cluster):
def fire_callback_on_before_deployment_check(cluster, nodes=None):
for extension in get_all_extensions():
for extension in _collect_extensions_for_cluster(cluster):
extension.on_before_deployment_check(cluster, nodes or cluster.nodes)
def fire_callback_on_before_deployment_serialization(cluster, nodes,
ignore_customized):
for extension in get_all_extensions():
for extension in _collect_extensions_for_cluster(cluster):
extension.on_before_deployment_serialization(
cluster, nodes, ignore_customized
)
@ -181,16 +187,19 @@ def fire_callback_on_before_deployment_serialization(cluster, nodes,
def fire_callback_on_before_provisioning_serialization(cluster, nodes,
ignore_customized):
for extension in get_all_extensions():
for extension in _collect_extensions_for_cluster(cluster):
extension.on_before_provisioning_serialization(
cluster, nodes, ignore_customized
)
def _collect_data_pipelines_for_cluster(cluster):
extensions = set(cluster.extensions)
return chain.from_iterable(e.data_pipelines for e in get_all_extensions()
if e.name in extensions)
return chain.from_iterable(
e.data_pipelines for e in _collect_extensions_for_cluster(cluster))
def _collect_extensions_for_cluster(cluster):
return [get_extension(e) for e in set(cluster.extensions)]
def fire_callback_on_node_serialization_for_deployment(node, node_data):

View File

@ -2022,7 +2022,7 @@
- network
- storage
modes: ['ha_compact']
extensions: ['volume_manager']
extensions: ['volume_manager', 'network_manager']
- pk: 1
extend: *base_release
fields:

View File

@ -25,6 +25,7 @@ from nailgun.db.sqlalchemy.models import Node
from nailgun import errors
from nailgun.test.base import BaseIntegrationTest
from nailgun.test.base import fake_tasks
from nailgun.test.utils import make_mock_extensions
from nailgun.utils import reverse
@ -394,3 +395,63 @@ class TestClusterComponents(BaseIntegrationTest):
headers=self.default_headers,
expect_errors=True
)
class TestClusterExtension(BaseIntegrationTest):
def setUp(self):
super(TestClusterExtension, self).setUp()
self.env.create_cluster()
self.cluster = self.env.clusters[0]
def test_get_enabled_extensions(self):
enabled_extensions = 'volume_manager', 'bareon'
self.cluster.extensions = enabled_extensions
self.db.commit()
resp = self.app.get(
reverse(
'ClusterExtensionsHandler',
kwargs={'cluster_id': self.cluster.id}),
headers=self.default_headers,
)
self.assertEqual(resp.status_code, 200)
self.assertItemsEqual(resp.json_body, enabled_extensions)
def test_enabling_extensions(self):
extensions = 'bareon', 'volume_manager'
with mock.patch(
'nailgun.api.v1.validators.extension.get_all_extensions',
return_value=make_mock_extensions(extensions)):
resp = self.app.put(
reverse(
'ClusterExtensionsHandler',
kwargs={'cluster_id': self.cluster.id}),
jsonutils.dumps(extensions),
headers=self.default_headers,
)
self.assertEqual(resp.status_code, 200)
self.db.refresh(self.cluster)
self.assertItemsEqual(self.cluster.extensions, extensions)
def test_enabling_invalid_extensions(self):
existed_extensions = 'bareon', 'volume_manager'
requested_extensions = 'network_manager', 'volume_manager'
with mock.patch(
'nailgun.api.v1.validators.extension.get_all_extensions',
return_value=make_mock_extensions(existed_extensions)):
resp = self.app.put(
reverse(
'ClusterExtensionsHandler',
kwargs={'cluster_id': self.cluster.id}),
jsonutils.dumps(requested_extensions),
headers=self.default_headers,
expect_errors=True,
)
self.assertEqual(resp.status_code, 400)
self.assertIn(u"No such extensions:", resp.json_body['message'])
self.assertIn(requested_extensions[0], resp.json_body['message'])
self.assertNotIn(requested_extensions[1], resp.json_body['message'])

View File

@ -15,10 +15,16 @@
# under the License.
import mock
from oslo_serialization import jsonutils
from nailgun.api.v1.validators.extension import ExtensionValidator
from nailgun import errors
from nailgun.extensions import BaseExtension
from nailgun.extensions import BasePipeline
from nailgun.extensions import fire_callback_on_before_deployment_check
from nailgun.extensions import fire_callback_on_before_deployment_serialization
from nailgun.extensions import \
fire_callback_on_before_provisioning_serialization
from nailgun.extensions import fire_callback_on_cluster_delete
from nailgun.extensions import fire_callback_on_node_collection_delete
from nailgun.extensions import fire_callback_on_node_create
@ -29,6 +35,7 @@ from nailgun.extensions import get_extension
from nailgun.extensions import node_extension_call
from nailgun.extensions import setup_yaql_context
from nailgun.test.base import BaseTestCase
from nailgun.test.utils import make_mock_extensions
class BaseExtensionCase(BaseTestCase):
@ -65,20 +72,6 @@ class TestBaseExtension(BaseExtensionCase):
'ext_name-1.0.0')
def make_mock_extensions(names=('ex1', 'ex2')):
mocks = []
for name in names:
# NOTE(eli): since 'name' is reserved world
# for mock constructor, we should assign
# name explicitly
ex_m = mock.MagicMock()
ex_m.name = name
ex_m.provides = ['method_call']
mocks.append(ex_m)
return mocks
class TestExtensionUtils(BaseTestCase):
def make_node(self, node_extensions=[], cluster_extensions=[]):
@ -199,6 +192,53 @@ class TestExtensionUtils(BaseTestCase):
for ext in get_m.return_value:
ext.on_cluster_delete.assert_called_once_with(cluster)
@mock.patch('nailgun.extensions.manager.get_all_extensions',
return_value=make_mock_extensions())
def test_fire_callback_on_before_deployment_check(self, get_m):
cluster = mock.MagicMock()
cluster.extensions = ['ex1']
fire_callback_on_before_deployment_check(cluster, mock.sentinel.nodes)
ex1 = get_m.return_value[0]
self.assertEqual('ex1', ex1.name)
ex1.on_before_deployment_check.assert_called_once_with(
cluster, mock.sentinel.nodes)
ex2 = get_m.return_value[1]
self.assertEqual('ex2', ex2.name)
self.assertFalse(ex2.on_before_deployment_check.called)
@mock.patch('nailgun.extensions.manager.get_all_extensions',
return_value=make_mock_extensions())
def test_fire_callback_on_before_deployment_serialization(self, get_m):
cluster = mock.MagicMock()
cluster.extensions = ['ex1']
fire_callback_on_before_deployment_serialization(
cluster, mock.sentinel.nodes, mock.sentinel.ignore_customized)
ex1 = get_m.return_value[0]
self.assertEqual('ex1', ex1.name)
ex1.on_before_deployment_serialization.assert_called_once_with(
cluster, mock.sentinel.nodes, mock.sentinel.ignore_customized)
ex2 = get_m.return_value[1]
self.assertEqual('ex2', ex2.name)
self.assertFalse(ex2.on_before_deployment_serialization.called)
@mock.patch('nailgun.extensions.manager.get_all_extensions',
return_value=make_mock_extensions())
def test_fire_callback_on_before_provisioning_serialization(self, get_m):
cluster = mock.MagicMock()
cluster.extensions = ['ex1']
fire_callback_on_before_provisioning_serialization(
cluster, mock.sentinel.nodes, mock.sentinel.ignore_customized)
ex1 = get_m.return_value[0]
self.assertEqual('ex1', ex1.name)
ex1.on_before_provisioning_serialization.assert_called_once_with(
cluster, mock.sentinel.nodes, mock.sentinel.ignore_customized)
ex2 = get_m.return_value[1]
self.assertEqual('ex2', ex2.name)
self.assertFalse(ex2.on_before_provisioning_serialization.called)
@mock.patch('nailgun.extensions.manager.get_all_extensions',
return_value=make_mock_extensions())
def test_setup_yaql_context(self, get_m):
@ -242,3 +282,28 @@ class TestPipeline(BaseExtensionCase):
@classmethod
def process_deployment_for_node(cls, cluster, cluster_data):
pass
class TestExtensionValidator(BaseTestCase):
def test_validate_extensions(self):
global_exts = 'volume_manager', 'bareon', 'ultralogger'
with mock.patch(
'nailgun.api.v1.validators.extension.get_all_extensions',
return_value=make_mock_extensions(global_exts)):
ExtensionValidator.validate(jsonutils.dumps(global_exts))
def test_invalid_extension(self):
global_exts = 'volume_manager', 'bareon', 'ultralogger'
data = 'volume_manager', 'baleron'
with mock.patch(
'nailgun.api.v1.validators.extension.get_all_extensions',
return_value=make_mock_extensions(global_exts)):
with self.assertRaisesRegexp(errors.CannotFindExtension,
'No such extensions: baleron'):
ExtensionValidator.validate(jsonutils.dumps(data))

View File

@ -18,6 +18,8 @@ import random
import six
import string
import mock
def random_string(lenght, charset=None):
"""Returns a random string of the specified length
@ -33,3 +35,17 @@ def random_string(lenght, charset=None):
return ''.join([str(random.choice(charset))
for i in six.moves.range(lenght)])
def make_mock_extensions(names=('ex1', 'ex2')):
mocks = []
for name in names:
# NOTE(eli): since 'name' is reserved world
# for mock constructor, we should assign
# name explicitly
ex_m = mock.MagicMock()
ex_m.name = name
ex_m.provides = ['method_call']
mocks.append(ex_m)
return mocks