diff --git a/nailgun/nailgun/api/v1/handlers/cluster.py b/nailgun/nailgun/api/v1/handlers/cluster.py index 3f55ba8883..ffe3dac5a9 100644 --- a/nailgun/nailgun/api/v1/handlers/cluster.py +++ b/nailgun/nailgun/api/v1/handlers/cluster.py @@ -330,7 +330,8 @@ class VmwareAttributesHandler(BaseHandler): if not attributes: raise self.http(404, "No vmware attributes found") - if cluster.is_locked: + if cluster.is_locked and \ + not objects.Cluster.has_compute_vmware_changes(cluster): raise self.http(403, "Environment attributes can't be changed " "after or during deployment.") diff --git a/nailgun/nailgun/api/v1/validators/cluster.py b/nailgun/nailgun/api/v1/validators/cluster.py index 9b53f2a4d1..b58392d5d3 100644 --- a/nailgun/nailgun/api/v1/validators/cluster.py +++ b/nailgun/nailgun/api/v1/validators/cluster.py @@ -318,7 +318,7 @@ class AttributesValidator(BasicValidator): for attrs in data.get('editable', {}).values(): if not isinstance(attrs, dict): continue - for attr_name, attr in attrs.items(): + for attr_name, attr in six.iteritems(attrs): cls.validate_attribute(attr_name, attr) return data @@ -441,8 +441,245 @@ class VmwareAttributesValidator(BasicValidator): single_schema = cluster_schema.vmware_attributes_schema + @staticmethod + def _get_target_node_id(nova_compute_data): + return nova_compute_data['target_node']['current']['id'] + @classmethod - def validate(cls, data, instance=None): + def _validate_updated_attributes(cls, attributes, instance): + """Validate that attributes contains changes only for allowed fields. + + :param attributes: new vmware attribute settings for db instance + :param instance: nailgun.db.sqlalchemy.models.VmwareAttributes instance + """ + metadata = instance.editable.get('metadata', {}) + db_editable_attributes = instance.editable.get('value', {}) + new_editable_attributes = attributes.get('editable', {}).get('value') + for attribute_metadata in metadata: + if attribute_metadata.get('type') == 'array': + attribute_name = attribute_metadata['name'] + cls._check_attribute( + attribute_metadata, + db_editable_attributes.get(attribute_name), + new_editable_attributes.get(attribute_name) + ) + else: + cls._check_attribute( + attribute_metadata, + db_editable_attributes, + new_editable_attributes + ) + + @classmethod + def _check_attribute(cls, metadata, attributes, new_attributes): + """Check new_attributes is equal with attributes except editable fields + + :param metadata: dict describes structure and properties of attributes + :param attributes: attributes which is the basis for comparison + :param new_attributes: attributes with modifications to check + """ + if type(attributes) != type(new_attributes): + raise errors.InvalidData( + "Value type of '{0}' attribute couldn't be changed.". + format(metadata.get('label') or metadata.get('name')), + log_message=True + ) + # if metadata field contains editable_for_deployed = True, attribute + # and all its childs may be changed too. No need to check it. + if metadata.get('editable_for_deployed'): + return + + # no 'fields' in metadata means that attribute has no any childs(leaf) + if 'fields' not in metadata: + if attributes != new_attributes: + raise errors.InvalidData( + "Value of '{0}' attribute couldn't be changed.". + format(metadata.get('label') or metadata.get('name')), + log_message=True + ) + return + + fields_sort_functions = { + 'availability_zones': lambda x: x['az_name'], + 'nova_computes': lambda x: x['vsphere_cluster'] + } + field_name = metadata['name'] + if isinstance(attributes, (list, tuple)): + if len(attributes) != len(new_attributes): + raise errors.InvalidData( + "Value of '{0}' attribute couldn't be changed.". + format(metadata.get('label') or metadata.get('name')), + log_message=True + ) + attributes = sorted( + attributes, key=fields_sort_functions.get(field_name)) + new_attributes = sorted( + new_attributes, key=fields_sort_functions.get(field_name)) + for item, new_item in six.moves.zip(attributes, new_attributes): + for field_metadata in metadata['fields']: + cls._check_attribute(field_metadata, + item.get(field_metadata['name']), + new_item.get(field_metadata['name'])) + elif isinstance(attributes, dict): + for field_metadata in metadata['fields']: + cls._check_attribute(field_metadata, + attributes.get(field_name), + new_attributes.get(field_name)) + + @classmethod + def _validate_nova_computes(cls, attributes, instance): + """Validates a 'nova_computes' attributes from vmware_attributes + + Raise InvalidData exception if new attributes is not valid. + + :param instance: nailgun.db.sqlalchemy.models.VmwareAttributes instance + :param attributes: new attributes for db instance for validation + """ + input_nova_computes = objects.VmwareAttributes.get_nova_computes_attrs( + attributes.get('editable')) + + cls.check_nova_compute_duplicate_and_empty_values(input_nova_computes) + + db_nova_computes = objects.VmwareAttributes.get_nova_computes_attrs( + instance.editable) + if instance.cluster.is_locked: + cls.check_operational_controllers_settings(input_nova_computes, + db_nova_computes) + operational_compute_nodes = objects.Cluster.\ + get_operational_vmware_compute_nodes(instance.cluster) + cls.check_operational_node_settings( + input_nova_computes, db_nova_computes, operational_compute_nodes) + + @classmethod + def check_nova_compute_duplicate_and_empty_values(cls, attributes): + """Check 'nova_computes' attributes for empty and duplicate values.""" + nova_compute_attributes_sets = { + 'vsphere_cluster': set(), + 'service_name': set(), + 'target_node': set() + } + for nova_compute_data in attributes: + for attr, values in six.iteritems(nova_compute_attributes_sets): + if attr == 'target_node': + settings_value = cls._get_target_node_id(nova_compute_data) + if settings_value == 'controllers': + continue + else: + settings_value = nova_compute_data.get(attr) + if not settings_value: + raise errors.InvalidData( + "Empty value for attribute '{0}' is not allowed". + format(attr), + log_message=True + ) + if settings_value in values: + raise errors.InvalidData( + "Duplicate value '{0}' for attribute '{1}' is " + "not allowed".format(settings_value, attr), + log_message=True + ) + values.add(settings_value) + + @classmethod + def check_operational_node_settings(cls, input_nova_computes, + db_nova_computes, operational_nodes): + """Validates a 'nova_computes' attributes for operational compute nodes + + Raise InvalidData exception if nova_compute settings will be changed or + deleted for deployed nodes with role 'compute-vmware' that wasn't + marked for deletion + + :param input_nova_computes: new nova_compute attributes + :type input_nova_computes: list of dicts + :param db_nova_computes: nova_computes attributes stored in db + :type db_nova_computes: list of dicts + :param operational_nodes: list of operational vmware-compute nodes + :type operational_nodes: list of nailgun.db.sqlalchemy.models.Node + """ + input_computes_by_node_name = dict( + (cls._get_target_node_id(nc), nc) for nc in input_nova_computes) + db_computes_by_node_name = dict( + (cls._get_target_node_id(nc), nc) for nc in db_nova_computes) + + for node in operational_nodes: + node_hostname = node.hostname + input_nova_compute = input_computes_by_node_name.get(node_hostname) + if not input_nova_compute: + raise errors.InvalidData( + "The following compute-vmware node couldn't be " + "deleted from vSphere cluster: {0}".format(node.name), + log_message=True + ) + db_nova_compute = db_computes_by_node_name.get(node_hostname) + for attr, db_value in six.iteritems(db_nova_compute): + if attr != 'target_node' and \ + db_value != input_nova_compute.get(attr): + raise errors.InvalidData( + "Parameter '{0}' of nova compute instance with target " + "node '{1}' couldn't be changed".format( + attr, node.name), + log_message=True + ) + + @classmethod + def check_operational_controllers_settings(cls, input_nova_computes, + db_nova_computes): + """Check deployed nova computes settings with target = controllers. + + Raise InvalidData exception if any deployed nova computes clusters with + target 'controllers' were added, removed or modified. + + :param input_nova_computes: new nova_compute settings + :type input_nova_computes: list of dicts + :param db_nova_computes: nova_computes settings stored in db + :type db_nova_computes: list of dicts + """ + input_computes_by_vsphere_name = dict( + (nc['vsphere_cluster'], nc) for nc in input_nova_computes if + cls._get_target_node_id(nc) == 'controllers' + ) + db_clusters_names = set() + for db_nova_compute in db_nova_computes: + target_name = cls._get_target_node_id(db_nova_compute) + if target_name == 'controllers': + vsphere_name = db_nova_compute['vsphere_cluster'] + input_nova_compute = \ + input_computes_by_vsphere_name.get(vsphere_name) + if not input_nova_compute: + raise errors.InvalidData( + "Nova compute instance with target 'controllers' and " + "vSphere cluster {0} couldn't be deleted from " + "operational environment.".format(vsphere_name), + log_message=True + ) + for attr, db_value in six.iteritems(db_nova_compute): + input_value = input_nova_compute.get(attr) + if attr == 'target_node': + db_value = cls._get_target_node_id(db_nova_compute) + input_value = cls._get_target_node_id( + input_nova_compute) + if db_value != input_value: + raise errors.InvalidData( + "Parameter '{0}' of nova compute instance with " + "vSphere cluster name '{1}' couldn't be changed". + format(attr, vsphere_name), + log_message=True + ) + db_clusters_names.add(vsphere_name) + + input_clusters_names = set(input_computes_by_vsphere_name) + if input_clusters_names - db_clusters_names: + raise errors.InvalidData( + "Nova compute instances with target 'controllers' couldn't be " + "added to operational environment. Check nova compute " + "instances with the following vSphere cluster names: {0}". + format(', '.join( + sorted(input_clusters_names - db_clusters_names))), + log_message=True + ) + + @classmethod + def validate(cls, data, instance): d = cls.validate_json(data) if 'metadata' in d.get('editable'): db_metadata = instance.editable.get('metadata') @@ -453,6 +690,10 @@ class VmwareAttributesValidator(BasicValidator): log_message=True ) + if instance.cluster.is_locked: + cls._validate_updated_attributes(d, instance) + cls._validate_nova_computes(d, instance) + # TODO(apopovych): write validation processing from # openstack.yaml for vmware return d diff --git a/nailgun/nailgun/fixtures/openstack.yaml b/nailgun/nailgun/fixtures/openstack.yaml index 4c87dc4d5f..806c30eda8 100644 --- a/nailgun/nailgun/fixtures/openstack.yaml +++ b/nailgun/nailgun/fixtures/openstack.yaml @@ -1529,6 +1529,7 @@ - name: "nova_computes" type: "array" + editable_for_deployed: true fields: - name: "vsphere_cluster" diff --git a/nailgun/nailgun/objects/cluster.py b/nailgun/nailgun/objects/cluster.py index 7343250bb5..51dcbfb458 100644 --- a/nailgun/nailgun/objects/cluster.py +++ b/nailgun/nailgun/objects/cluster.py @@ -1284,6 +1284,31 @@ class Cluster(NailgunObject): instance.nodes if nodes is None else nodes ) + @classmethod + def has_compute_vmware_changes(cls, instance): + """Checks if any 'compute-vmware' nodes are waiting for deployment. + + :param instance: cluster for checking + :type instance: nailgun.db.sqlalchemy.models.Cluster instance + """ + compute_vmware_nodes_query = db().query(models.Node).filter_by( + cluster_id=instance.id + ).filter(sa.or_( + sa.and_(models.Node.roles.any('compute-vmware'), + models.Node.pending_deletion), + models.Node.pending_roles.any('compute-vmware') + )) + return db().query(compute_vmware_nodes_query.exists()).scalar() + + @classmethod + def get_operational_vmware_compute_nodes(cls, instance): + return db().query(models.Node).filter_by( + cluster_id=instance.id + ).filter( + models.Node.roles.any('compute-vmware'), + sa.not_(models.Node.pending_deletion) + ).all() + class ClusterCollection(NailgunCollection): """Cluster collection.""" @@ -1294,3 +1319,22 @@ class ClusterCollection(NailgunCollection): class VmwareAttributes(NailgunObject): model = models.VmwareAttributes + + @staticmethod + def get_nova_computes_attrs(attributes): + return attributes.get('value', {}).get( + 'availability_zones', [{}])[0].get('nova_computes', []) + + @classmethod + def get_nova_computes_target_nodes(cls, instance): + """Get data of targets node for all nova computes. + + :param instance: nailgun.db.sqlalchemy.models.Cluster instance + :returns: list of dicts that represents nova compute targets + """ + nova_compute_target_nodes = [] + for nova_compute in cls.get_nova_computes_attrs(instance.editable): + target = nova_compute['target_node']['current'] + if target['id'] != 'controllers': + nova_compute_target_nodes.append(target) + return nova_compute_target_nodes diff --git a/nailgun/nailgun/task/task.py b/nailgun/nailgun/task/task.py index f2d63a6ea8..642666fcbb 100644 --- a/nailgun/nailgun/task/task.py +++ b/nailgun/nailgun/task/task.py @@ -1267,13 +1267,18 @@ class CheckBeforeDeploymentTask(object): vmware_attributes = task.cluster.vmware_attributes # Old(< 6.1) clusters haven't vmware support if vmware_attributes: - cinder_nodes = filter( - lambda node: 'cinder' in node.all_roles, - task.cluster.nodes) + cinder_nodes = [node for node in task.cluster.nodes if + 'cinder' in node.all_roles] if not cinder_nodes: logger.info('There is no any node with "cinder" role provided') + compute_vmware_nodes = [node for node in task.cluster.nodes if + 'compute-vmware' in node.all_roles] + if compute_vmware_nodes: + cls._check_vmware_nova_computes(compute_vmware_nodes, + vmware_attributes) + models = { 'settings': attributes, 'default': vmware_attributes.editable, @@ -1290,6 +1295,63 @@ class CheckBeforeDeploymentTask(object): if errors_msg: raise errors.CheckBeforeDeploymentError('\n'.join(errors_msg)) + @classmethod + def _check_vmware_nova_computes(cls, compute_vmware_nodes, attributes): + """Check that nova computes settings is correct for cluster nodes + + :param compute_vmware_nodes: all node with role compute-vmware that + belongs to cluster + :type compute_vmware_nodes: list of nailgun.db.sqlalchemy.models.Node + instances + :param attributes: cluster vmware_attributes + :type attributes: nailgun.db.sqlalchemy.models.VmwareAttributes + :raises: errors.CheckBeforeDeploymentError + """ + compute_nodes_targets = \ + objects.VmwareAttributes.get_nova_computes_target_nodes(attributes) + compute_nodes_hostnames = set([t['id'] for t in compute_nodes_targets]) + + errors_msg = [] + cluster_nodes_hostname = set() + not_deleted_nodes_from_computes = set() + not_assigned_nodes_to_computes = set() + for node in compute_vmware_nodes: + node_hostname = node.hostname + if node.pending_deletion: + if node_hostname in compute_nodes_hostnames: + not_deleted_nodes_from_computes.add(node.name) + elif node_hostname not in compute_nodes_hostnames: + not_assigned_nodes_to_computes.add(node.name) + + cluster_nodes_hostname.add(node_hostname) + + if not_assigned_nodes_to_computes: + errors_msg.append( + "The following compute-vmware nodes are not assigned to " + "any vCenter cluster: {0}".format( + ', '.join(sorted(not_assigned_nodes_to_computes)) + ) + ) + if not_deleted_nodes_from_computes: + errors_msg.append( + "The following nodes are prepared for deletion and " + "couldn't be assigned to any vCenter cluster: {0}".format( + ', '.join(sorted(not_deleted_nodes_from_computes)) + ), + ) + + alien_nodes_names = [t['label'] for t in compute_nodes_targets if + t['id'] not in cluster_nodes_hostname] + if alien_nodes_names: + errors_msg.append( + "The following nodes don't belong to compute-vmware nodes of " + "environment and couldn't be assigned to any vSphere cluster: " + "{0}".format(', '.join(sorted(alien_nodes_names))) + ) + + if errors_msg: + raise errors.CheckBeforeDeploymentError('\n'.join(errors_msg)) + @classmethod def _validate_network_template(cls, task): cluster = task.cluster diff --git a/nailgun/nailgun/test/base.py b/nailgun/nailgun/test/base.py index 2cdf10b5b5..3348104cce 100644 --- a/nailgun/nailgun/test/base.py +++ b/nailgun/nailgun/test/base.py @@ -243,8 +243,8 @@ class EnvironmentManager(object): cluster_data = { 'name': 'cluster-api-' + str(randint(0, 1000000)), } - editable_attributes = kwargs.pop( - 'editable_attributes', None) + editable_attributes = kwargs.pop('editable_attributes', None) + vmware_attributes = kwargs.pop('vmware_attributes', None) if kwargs: cluster_data.update(kwargs) @@ -277,6 +277,8 @@ class EnvironmentManager(object): if editable_attributes: Cluster.patch_attributes(cluster_db, {'editable': editable_attributes}) + if vmware_attributes: + Cluster.update_vmware_attributes(cluster_db, vmware_attributes) return cluster def create_node( diff --git a/nailgun/nailgun/test/integration/test_attributes.py b/nailgun/nailgun/test/integration/test_attributes.py index 47cf685492..ada8e60366 100644 --- a/nailgun/nailgun/test/integration/test_attributes.py +++ b/nailgun/nailgun/test/integration/test_attributes.py @@ -14,6 +14,7 @@ # License for the specific language governing permissions and limitations # under the License. +from mock import patch import six from oslo_serialization import jsonutils @@ -653,6 +654,49 @@ class TestVmwareAttributes(BaseIntegrationTest): resp.json_body["message"] ) + @patch('nailgun.db.sqlalchemy.models.Cluster.is_locked', return_value=True) + def test_vmware_attributes_update_for_locked_cluster_403(self, locked): + self._set_use_vcenter(self.cluster_db) + resp = self.app.put( + reverse( + 'VmwareAttributesHandler', + kwargs={'cluster_id': self.cluster_db.id}), + params=jsonutils.dumps({ + "editable": { + "value": {"foo": "bar"} + } + }), + headers=self.default_headers, + expect_errors=True + ) + self.assertEqual(403, resp.status_code) + self.assertEqual("Environment attributes can't be changed after or " + "during deployment.", resp.json_body["message"]) + + @patch('objects.Cluster.has_compute_vmware_changes', return_value=True) + @patch('nailgun.db.sqlalchemy.models.Cluster.is_locked', return_value=True) + def test_vmware_attributes_update_for_locked_cluster_200( + self, is_locked_mock, has_compute_mock): + self._set_use_vcenter(self.cluster_db) + params = { + "editable": { + "value": {"foo": "bar"} + }} + with patch('nailgun.api.v1.handlers.cluster.VmwareAttributesHandler.' + 'checked_data', return_value=params): + resp = self.app.put( + reverse( + 'VmwareAttributesHandler', + kwargs={'cluster_id': self.cluster_db.id}), + params=jsonutils.dumps(params), + headers=self.default_headers + ) + self.assertEqual(200, resp.status_code) + attrs = objects.Cluster.get_vmware_attributes(self.cluster_db) + self.assertEqual('bar', attrs.editable.get('value', {}).get('foo')) + attrs.editable.get('value', {}).pop('foo') + self.assertEqual(attrs.editable.get('value'), {}) + def _set_use_vcenter(self, cluster): cluster_attrs = objects.Cluster.get_editable_attributes(cluster) cluster_attrs['common']['use_vcenter']['value'] = True diff --git a/nailgun/nailgun/test/unit/test_objects.py b/nailgun/nailgun/test/unit/test_objects.py index 52fdcc62e3..c8a2f73609 100644 --- a/nailgun/nailgun/test/unit/test_objects.py +++ b/nailgun/nailgun/test/unit/test_objects.py @@ -1296,6 +1296,27 @@ class TestClusterObject(BaseTestCase): 'cluster': {u'net_provider': u'test_provider'}} ) + def test_cluster_has_compute_vmware_changes(self): + cluster = self.env.create_cluster(api=False) + ready_compute_vmware_node = self.env.create_node( + cluster_id=cluster.id, + roles=['compute-vmware'], + status=consts.NODE_STATUSES.ready + ) + self.env.create_node(cluster_id=cluster.id, pending_addition=True, + pending_roles=['controller']) + self.assertFalse(objects.Cluster.has_compute_vmware_changes(cluster)) + + pending_compute_vmware_node = self.env.create_node( + cluster_id=cluster.id, + pending_roles=["compute-vmware"] + ) + self.assertTrue(objects.Cluster.has_compute_vmware_changes(cluster)) + objects.Node.delete(pending_compute_vmware_node) + objects.Node.update( + ready_compute_vmware_node, {'pending_deletion': True}) + self.assertTrue(objects.Cluster.has_compute_vmware_changes(cluster)) + def test_enable_settings_by_components(self): components = [{ 'name': 'network:neutron:tun', diff --git a/nailgun/nailgun/test/unit/test_task.py b/nailgun/nailgun/test/unit/test_task.py index ee2b5099bc..2910b4a67b 100644 --- a/nailgun/nailgun/test/unit/test_task.py +++ b/nailgun/nailgun/test/unit/test_task.py @@ -609,6 +609,84 @@ class TestCheckBeforeDeploymentTask(BaseTestCase): _check_deployment_graph_for_correctness( self.task) + def test_check_missed_nodes_vmware_nova_computes(self): + operational_node = self.env.create_node( + roles=['compute-vmware'], + cluster_id=self.cluster.id, + name='node-1' + ) + pending_addition_node = self.env.create_node( + roles=['compute-vmware'], + cluster_id=self.cluster.id, + pending_addition=True, + name='node-2' + ) + msg = ("The following compute-vmware nodes are not assigned to " + "any vCenter cluster: {0}").format(', '.join( + sorted([operational_node.name, pending_addition_node.name]) + )) + with self.assertRaisesRegexp(errors.CheckBeforeDeploymentError, msg): + task.CheckBeforeDeploymentTask._check_vmware_consistency(self.task) + + @mock.patch('objects.VmwareAttributes.get_nova_computes_target_nodes') + def test_check_not_deleted_nodes_vmware_nova_computes(self, target_nodes): + operational_node = self.env.create_node( + roles=['compute-vmware'], + cluster_id=self.cluster.id, + name='node-1' + ) + pending_deletion_node = self.env.create_node( + roles=['compute-vmware'], + cluster_id=self.cluster.id, + pending_deletion=True, + name='node-2' + ) + target_nodes.return_value = [{ + 'id': operational_node.hostname, + 'label': operational_node.name + }, { + 'id': pending_deletion_node.hostname, + 'label': pending_deletion_node.name + }] + msg = ("The following nodes are prepared for deletion and couldn't be " + "assigned to any vCenter cluster: {0}".format( + pending_deletion_node.name)) + with self.assertRaisesRegexp(errors.CheckBeforeDeploymentError, msg): + task.CheckBeforeDeploymentTask._check_vmware_consistency(self.task) + + @mock.patch('objects.VmwareAttributes.get_nova_computes_target_nodes') + def test_check_extra_nodes_vmware_nova_computes(self, target_nodes): + operational_node = self.env.create_node( + roles=['compute-vmware'], + cluster_id=self.cluster.id, + name='node-1' + ) + non_cluster_node = self.env.create_node( + roles=['compute-vmware'], + name='node-2' + ) + other_role_node = self.env.create_node( + cluster_id=self.cluster.id, + name='node-3' + ) + target_nodes.return_value = [{ + 'id': operational_node.hostname, + 'label': operational_node.name + }, { + 'id': non_cluster_node.hostname, + 'label': non_cluster_node.name + }, { + 'id': other_role_node.hostname, + 'label': other_role_node.name + }] + msg = ("The following nodes don't belong to compute-vmware nodes of " + "environment and couldn't be assigned to any vSphere cluster: " + "{0}".format(', '.join( + sorted([non_cluster_node.name, other_role_node.name])) + )) + with self.assertRaisesRegexp(errors.CheckBeforeDeploymentError, msg): + task.CheckBeforeDeploymentTask._check_vmware_consistency(self.task) + class TestDeployTask(BaseTestCase): diff --git a/nailgun/nailgun/test/unit/test_vmware_attributes_validator.py b/nailgun/nailgun/test/unit/test_vmware_attributes_validator.py new file mode 100644 index 0000000000..2ac73796c5 --- /dev/null +++ b/nailgun/nailgun/test/unit/test_vmware_attributes_validator.py @@ -0,0 +1,444 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 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 copy import deepcopy +from mock import patch + +from nailgun.api.v1.validators.cluster import VmwareAttributesValidator +from nailgun import consts +from nailgun.errors import errors +from nailgun import objects +from nailgun.test.base import BaseTestCase + + +class TestAttributesValidator(BaseTestCase): + def setUp(self): + super(TestAttributesValidator, self).setUp() + self.env.create( + cluster_kwargs={ + "api": False, + "vmware_attributes": { + "editable": self._get_value_vmware_attributes() + } + }, + nodes_kwargs=[{ + "hostname": "controller-node", + "status": consts.NODE_STATUSES.ready + }] + ) + self.cluster = self.env.clusters[0] + self.ready_compute_node = self.env.create_node( + hostname="node-1", + name="Node 1", + roles=["compute-vmware"], + status=consts.NODE_STATUSES.ready, + cluster_id=self.cluster.id + ) + + def _get_target_id(self, nova_compute): + return nova_compute["target_node"]["current"]["id"] + + def _get_default_nova_computes(self): + return [ + { + "datastore_regex": ".*", + "vsphere_cluster": "Cluster1", + "target_node": { + "current": { + "id": "node-1", + "label": "node-1" + } + }, + "service_name": "ns1" + }, + { + "datastore_regex": ".*", + "vsphere_cluster": "Cluster0", + "target_node": { + "current": { + "id": "controllers", + "label": "controllers" + } + }, + "service_name": "ns0" + } + ] + + def _get_value_vmware_attributes(self, nova_computes=None): + return { + "value": { + "availability_zones": [{ + "vcenter_username": "admin", + "az_name": "vcenter", + "vcenter_password": "pass", + "vcenter_host": "172.16.0.254", + "nova_computes": + nova_computes or self._get_default_nova_computes() + }] + } + } + + def validate_nova_compute_raises_regexp(self, nova_computes, error_msg): + with self.assertRaisesRegexp(errors.InvalidData, error_msg): + VmwareAttributesValidator._validate_nova_computes( + {"editable": self._get_value_vmware_attributes(nova_computes)}, + self.cluster.vmware_attributes + ) + + def test_change_exist_nova_compute(self): + nova_computes = self._get_default_nova_computes() + changed_attribute = 'vsphere_cluster' + for nc in nova_computes: + if self._get_target_id(nc) == self.ready_compute_node.hostname: + nc[changed_attribute] = "ClusterXX" + break + + self.validate_nova_compute_raises_regexp( + nova_computes, + "Parameter '{0}' of nova compute instance with target node '{1}' " + "couldn't be changed".format( + changed_attribute, self.ready_compute_node.name + ) + ) + + def test_delete_operational_nova_compute_node(self): + nova_computes = [ + nc for nc in self._get_default_nova_computes() if + self._get_target_id(nc) != self.ready_compute_node.hostname + ] + self.validate_nova_compute_raises_regexp( + nova_computes, + "The following compute-vmware node couldn't be deleted from " + "vSphere cluster: {0}".format(self.ready_compute_node.name) + ) + + def test_duplicate_values_for_nova_computes(self): + for attr in ("vsphere_cluster", "target_node", "service_name"): + exist_nova_computes = self._get_default_nova_computes() + duplicate_value = exist_nova_computes[0][attr] + new_nova_compute = { + "datastore_regex": ".*", + "vsphere_cluster": "ClusterXX", + "target_node": { + "current": { + "id": "node-X", + "label": "node-X" + }, + }, + "service_name": "nsXX" + } + new_nova_compute.update({attr: duplicate_value}) + exist_nova_computes.append(new_nova_compute) + duplicate_value = duplicate_value if attr != "target_node" \ + else duplicate_value["current"]["id"] + + self.validate_nova_compute_raises_regexp( + exist_nova_computes, + "Duplicate value '{0}' for attribute '{1}' is not allowed". + format(duplicate_value, attr) + ) + + def test_empty_values_for_nova_computes(self): + nova_computes = self._get_default_nova_computes() + nova_computes[0]["vsphere_cluster"] = "" + self.validate_nova_compute_raises_regexp( + nova_computes, + "Empty value for attribute 'vsphere_cluster' is not allowed" + ) + + def test_nova_compute_setting_validate_pass(self): + new_compute_vmware_node = self.env.create_node( + hostname="node-2", + name="Node 2", + pending_roles=["compute-vmware"], + pending_addition=True, + cluster_id=self.cluster.id + ) + self.env.create_node( + hostname="node-3", + name="Node 3", + roles=["compute-vmware"], + pending_deletion=True, + cluster_id=self.cluster.id + ) + attributes = self.cluster.vmware_attributes + new_nova_computes = self._get_default_nova_computes() + new_nova_computes.extend([{ + "datastore_regex": ".*", + "vsphere_cluster": "Cluster2", + "target_node": { + "current": { + "id": new_compute_vmware_node.hostname, + "label": new_compute_vmware_node.name + } + }, + "service_name": "ns2" + }]) + self.assertNotRaises(errors.InvalidData, + VmwareAttributesValidator._validate_nova_computes, + {"editable": self._get_value_vmware_attributes( + new_nova_computes)}, + attributes) + + def test_change_controller_nova_computes_pass(self): + cluster = self.env.create( + cluster_kwargs={ + "api": False, + "status": consts.CLUSTER_STATUSES.new, + "vmware_attributes": { + "editable": self._get_value_vmware_attributes() + } + } + ) + new_nova_computes = [nc for nc in self._get_default_nova_computes() + if self._get_target_id(nc) == "controllers"] + new_nova_computes[0]["vsphere_cluster"] = "new vsphere name" + new_nova_computes.append({ + "datastore_regex": ".*", + "vsphere_cluster": "Cluster10", + "target_node": { + "current": { + "id": "controllers", + "label": "controllers" + } + }, + "service_name": "ns10" + }) + self.assertNotRaises(errors.InvalidData, + VmwareAttributesValidator._validate_nova_computes, + {"editable": self._get_value_vmware_attributes( + new_nova_computes)}, + cluster.vmware_attributes) + + @patch("nailgun.db.sqlalchemy.models.Cluster.is_locked", return_value=True) + def test_change_controllers_nova_compute_setting(self, lock_mock): + new_nova_computes = self._get_default_nova_computes() + changed_vsphere_cluster = None + changed_attribute = "service_name" + for nc in new_nova_computes: + if self._get_target_id(nc) == "controllers": + nc[changed_attribute] = "new_service_name" + changed_vsphere_cluster = nc + break + + self.validate_nova_compute_raises_regexp( + new_nova_computes, + "Parameter '{0}' of nova compute instance with vSphere cluster " + "name '{1}' couldn't be changed".format( + changed_attribute, changed_vsphere_cluster["vsphere_cluster"]) + ) + + @patch("nailgun.db.sqlalchemy.models.Cluster.is_locked", return_value=True) + def test_add_controllers_nova_compute_setting(self, lock_mock): + new_nova_computes = self._get_default_nova_computes() + new_nova_computes.extend([{ + "datastore_regex": ".*", + "vsphere_cluster": "Cluster20", + "target_node": { + "current": { + "id": "controllers", + "label": "controllers" + } + }, + "service_name": "ns20" + }, { + "datastore_regex": ".*", + "vsphere_cluster": "Cluster30", + "target_node": { + "current": { + "id": "controllers", + "label": "controllers" + } + }, + "service_name": "ns30" + }]) + self.validate_nova_compute_raises_regexp( + new_nova_computes, + "Nova compute instances with target 'controllers' couldn't be " + "added to operational environment. Check nova compute instances " + "with the following vSphere cluster names: {0}".format( + ", ".join(sorted(["Cluster30", "Cluster20"])) + ) + ) + + @patch("nailgun.db.sqlalchemy.models.Cluster.is_locked", return_value=True) + def test_remove_controllers_nova_compute_setting(self, lock_mock): + new_nova_computes = [nc for nc in self._get_default_nova_computes() + if self._get_target_id(nc) != "controllers"] + self.validate_nova_compute_raises_regexp( + new_nova_computes, + "Nova compute instance with target 'controllers' and vSphere " + "cluster {0} couldn't be deleted from operational environment." + .format("Cluster0") + ) + + def test_update_non_editable_attributes(self): + metadata = [ + { + "name": "foo", + "label": "foo", + "type": "object", + "fields": [{ + "name": "foo_field_name", + "label": "foo_field_name", + }] + }, { + "name": "availability_zones", + "label": "availability_zones", + "type": "array", + "fields": [{ + "name": "az_name", + "label": "az_name", + }, { + "name": "nova_computes", + "type": "array", + "fields": [{ + "name": "vsphere_cluster", + "label": "vsphere_cluster", + }, { + "name": "target_node", + "label": "target_node", + }] + }, { + "name": "vcenter_host", + "label": "vcenter_host", + }] + } + ] + db_attributes_value = { + "availability_zones": [{ + "az_name": "az_1", + "vcenter_host": "127.0.0.1", + "nova_computes": [{ + "vsphere_cluster": "Cluster1", + "target_node": { + "current": {"id": "node-1"} + } + }] + }], + "foo": { + "foo_field_name": "foo_field_value" + } + } + instance = objects.VmwareAttributes.create( + {"editable": {"metadata": metadata, "value": db_attributes_value}} + ) + + new_attributes = deepcopy(db_attributes_value) + new_attributes["foo"] = ["foo_field_name"] + msg = "Value type of 'foo_field_name' attribute couldn't be changed." + with self.assertRaisesRegexp(errors.InvalidData, msg): + VmwareAttributesValidator._validate_updated_attributes( + {"editable": {"value": new_attributes}}, + instance) + + new_attributes = deepcopy(db_attributes_value) + new_attributes["foo"]["foo_field_name"] = "new_foo_field_value" + msg = "Value of 'foo_field_name' attribute couldn't be changed." + with self.assertRaisesRegexp(errors.InvalidData, msg): + VmwareAttributesValidator._validate_updated_attributes( + {"editable": {"value": new_attributes}}, + instance) + + new_attributes = deepcopy(db_attributes_value) + new_attributes["availability_zones"].append({ + "az_name": "az_2", + "vcenter_host": "127.0.0.1", + "nova_computes": [] + }) + msg = "Value of 'availability_zones' attribute couldn't be changed." + with self.assertRaisesRegexp(errors.InvalidData, msg): + VmwareAttributesValidator._validate_updated_attributes( + {"editable": {"value": new_attributes}}, instance) + + new_attributes = deepcopy(db_attributes_value) + new_attributes["availability_zones"][0]["nova_computes"][0].update( + {"target_node": {"current": {"id": "node-2"}}} + ) + msg = "Value of 'target_node' attribute couldn't be changed." + with self.assertRaisesRegexp(errors.InvalidData, msg): + VmwareAttributesValidator._validate_updated_attributes( + {"editable": {"value": new_attributes}}, instance) + + def test_update_editable_attributes(self): + metadata = [ + { + "name": "foo", + "label": "foo", + "type": "object", + "editable_for_deployed": True, + "fields": [{ + "name": "foo_field_name", + "label": "foo_field_name", + }] + }, { + "name": "availability_zones", + "type": "array", + "label": "availability_zones", + "fields": [{ + "name": "az_name", + "label": "az_name", + }, { + "name": "nova_computes", + "editable_for_deployed": True, + "type": "array", + "fields": [{ + "name": "vsphere_cluster", + "label": "vsphere_cluster", + }, { + "name": "target_node", + "label": "target_node", + }] + }, { + "name": "vcenter_host", + "label": "vcenter_host", + }] + } + ] + db_attributes_value = { + "availability_zones": [{ + "az_name": "az_1", + "vcenter_host": "127.0.0.1", + "nova_computes": [{ + "vsphere_cluster": "Cluster1", + "target_node": { + "current": {"id": "node-1"} + } + }] + }], + "foo": { + "foo_field_name": "foo_field_value" + } + } + instance = objects.VmwareAttributes.create( + {"editable": {"metadata": metadata, "value": db_attributes_value}} + ) + + new_attributes = deepcopy(db_attributes_value) + new_attributes["foo"]["foo_field_name"] = 1 + new_attributes["availability_zones"][0]["nova_computes"][0].update( + {"target_node": {"current": {"id": "node-2"}}} + ) + new_attributes["availability_zones"][0]["nova_computes"].append({ + "vsphere_cluster": "Cluster2", + "target_node": { + "current": {"id": "node-2"} + } + }) + self.assertNotRaises( + errors.InvalidData, + VmwareAttributesValidator._validate_updated_attributes, + {"editable": {"value": new_attributes}}, + instance)