From eaddb942fd9785427b63b21445d31d0aae49751f Mon Sep 17 00:00:00 2001 From: Jaycen Grant Date: Wed, 10 Aug 2016 13:29:31 -0700 Subject: [PATCH] Rename Bay to Cluster in api This is the first of several patches to add new Cluster commands that will replace the Bay terminalogy in Magnum. This patch adds the new Cluster and ClusterTemplate commands in addition to the Bay and Baymodel commands. Additional patches will be created for client, docs, and additional functional tests. Change-Id: Ie686281a6f98a1a9931158d2a79eee6ac21ed9a1 Implements: blueprint rename-bay-to-cluster --- etc/magnum/policy.json | 15 + magnum/api/controllers/root.py | 2 +- magnum/api/controllers/v1/__init__.py | 23 + magnum/api/controllers/v1/bay.py | 2 +- magnum/api/controllers/v1/baymodel.py | 4 +- magnum/api/controllers/v1/certificate.py | 2 +- magnum/api/controllers/v1/cluster.py | 468 +++++++ magnum/api/controllers/v1/cluster_template.py | 434 +++++++ magnum/common/exception.py | 25 +- magnum/conductor/handlers/bay_conductor.py | 4 +- magnum/db/api.py | 4 +- magnum/db/sqlalchemy/api.py | 35 +- magnum/service/periodic.py | 2 +- .../api/v1/clients/cluster_client.py | 176 +++ .../api/v1/clients/cluster_template_client.py | 113 ++ .../api/v1/models/cluster_id_model.py | 24 + .../functional/api/v1/models/cluster_model.py | 30 + .../api/v1/models/cluster_template_model.py | 30 + .../v1/models/cluster_templatepatch_model.py | 77 ++ .../api/v1/models/clusterpatch_model.py | 76 ++ magnum/tests/functional/api/v1/test_bay.py | 85 -- .../tests/functional/api/v1/test_cluster.py | 240 ++++ .../api/v1/test_cluster_template.py | 239 ++++ magnum/tests/functional/common/base.py | 6 +- magnum/tests/functional/common/datagen.py | 278 +++++ magnum/tests/functional/common/manager.py | 18 +- .../tests/unit/api/controllers/test_root.py | 23 +- .../unit/api/controllers/v1/test_cluster.py | 895 ++++++++++++++ .../controllers/v1/test_cluster_template.py | 1088 +++++++++++++++++ magnum/tests/unit/api/utils.py | 19 + .../conductor/handlers/test_bay_conductor.py | 2 +- magnum/tests/unit/db/test_bay.py | 14 +- magnum/tests/unit/db/test_baymodel.py | 16 +- magnum/tests/unit/objects/utils.py | 38 +- 34 files changed, 4352 insertions(+), 155 deletions(-) create mode 100644 magnum/api/controllers/v1/cluster.py create mode 100644 magnum/api/controllers/v1/cluster_template.py create mode 100644 magnum/tests/functional/api/v1/clients/cluster_client.py create mode 100644 magnum/tests/functional/api/v1/clients/cluster_template_client.py create mode 100644 magnum/tests/functional/api/v1/models/cluster_id_model.py create mode 100644 magnum/tests/functional/api/v1/models/cluster_model.py create mode 100644 magnum/tests/functional/api/v1/models/cluster_template_model.py create mode 100644 magnum/tests/functional/api/v1/models/cluster_templatepatch_model.py create mode 100644 magnum/tests/functional/api/v1/models/clusterpatch_model.py create mode 100644 magnum/tests/functional/api/v1/test_cluster.py create mode 100644 magnum/tests/functional/api/v1/test_cluster_template.py create mode 100644 magnum/tests/unit/api/controllers/v1/test_cluster.py create mode 100644 magnum/tests/unit/api/controllers/v1/test_cluster_template.py diff --git a/etc/magnum/policy.json b/etc/magnum/policy.json index 7e49e1d1f0..2cea1a6e89 100644 --- a/etc/magnum/policy.json +++ b/etc/magnum/policy.json @@ -20,6 +20,21 @@ "baymodel:update": "rule:default", "baymodel:publish": "rule:admin_or_owner", + "cluster:create": "rule:default", + "cluster:delete": "rule:default", + "cluster:detail": "rule:default", + "cluster:get": "rule:default", + "cluster:get_all": "rule:default", + "cluster:update": "rule:default", + + "clustertemplate:create": "rule:default", + "clustertemplate:delete": "rule:default", + "clustertemplate:detail": "rule:default", + "clustertemplate:get": "rule:default", + "clustertemplate:get_all": "rule:default", + "clustertemplate:update": "rule:default", + "clustertemplate:publish": "rule:admin_or_owner", + "rc:create": "rule:default", "rc:delete": "rule:default", "rc:detail": "rule:default", diff --git a/magnum/api/controllers/root.py b/magnum/api/controllers/root.py index bf5fc718fd..0d29117dd9 100644 --- a/magnum/api/controllers/root.py +++ b/magnum/api/controllers/root.py @@ -69,7 +69,7 @@ class Root(base.APIBase): root = Root() root.name = "OpenStack Magnum API" root.description = ("Magnum is an OpenStack project which aims to " - "provide container management.") + "provide container cluster management.") root.versions = [Version.convert('v1', "CURRENT", versions.CURRENT_MAX_VER, versions.BASE_VER)] diff --git a/magnum/api/controllers/v1/__init__.py b/magnum/api/controllers/v1/__init__.py index e45d3b3f15..92ce996b00 100644 --- a/magnum/api/controllers/v1/__init__.py +++ b/magnum/api/controllers/v1/__init__.py @@ -27,6 +27,8 @@ from magnum.api.controllers import link from magnum.api.controllers.v1 import bay from magnum.api.controllers.v1 import baymodel from magnum.api.controllers.v1 import certificate +from magnum.api.controllers.v1 import cluster +from magnum.api.controllers.v1 import cluster_template from magnum.api.controllers.v1 import magnum_services from magnum.api.controllers import versions as ver from magnum.api import expose @@ -77,6 +79,12 @@ class V1(controllers_base.APIBase): bays = [link.Link] """Links to the bays resource""" + clustertemplates = [link.Link] + """Links to the clustertemplates resource""" + + clusters = [link.Link] + """Links to the clusters resource""" + certificates = [link.Link] """Links to the certificates resource""" @@ -108,6 +116,19 @@ class V1(controllers_base.APIBase): pecan.request.host_url, 'bays', '', bookmark=True)] + v1.clustertemplates = [link.Link.make_link('self', + pecan.request.host_url, + 'clustertemplates', ''), + link.Link.make_link('bookmark', + pecan.request.host_url, + 'clustertemplates', '', + bookmark=True)] + v1.clusters = [link.Link.make_link('self', pecan.request.host_url, + 'clusters', ''), + link.Link.make_link('bookmark', + pecan.request.host_url, + 'clusters', '', + bookmark=True)] v1.certificates = [link.Link.make_link('self', pecan.request.host_url, 'certificates', ''), link.Link.make_link('bookmark', @@ -128,6 +149,8 @@ class Controller(controllers_base.Controller): bays = bay.BaysController() baymodels = baymodel.BayModelsController() + clusters = cluster.ClustersController() + clustertemplates = cluster_template.ClusterTemplatesController() certificates = certificate.CertificateController() mservices = magnum_services.MagnumServiceController() diff --git a/magnum/api/controllers/v1/bay.py b/magnum/api/controllers/v1/bay.py index 99c84727a8..813955d954 100644 --- a/magnum/api/controllers/v1/bay.py +++ b/magnum/api/controllers/v1/bay.py @@ -80,7 +80,7 @@ class Bay(base.APIBase): try: baymodel = api_utils.get_resource('BayModel', value) self._baymodel_id = baymodel.uuid - except exception.BayModelNotFound as e: + except exception.ClusterTemplateNotFound as e: # Change error code because 404 (NotFound) is inappropriate # response for a POST request to create a Bay e.code = 400 # BadRequest diff --git a/magnum/api/controllers/v1/baymodel.py b/magnum/api/controllers/v1/baymodel.py index e5b6a80c2e..bb25c8a0b2 100644 --- a/magnum/api/controllers/v1/baymodel.py +++ b/magnum/api/controllers/v1/baymodel.py @@ -343,7 +343,7 @@ class BayModelsController(base.Controller): if baymodel_dict['public']: if not policy.enforce(context, "baymodel:publish", None, do_raise=False): - raise exception.BaymodelPublishDenied() + raise exception.ClusterTemplatePublishDenied() # NOTE(yuywz): We will generate a random human-readable name for # baymodel if the name is not spcified by user. @@ -386,7 +386,7 @@ class BayModelsController(base.Controller): if baymodel.public != new_baymodel.public: if not policy.enforce(context, "baymodel:publish", None, do_raise=False): - raise exception.BaymodelPublishDenied() + raise exception.ClusterTemplatePublishDenied() # Update only the fields that have changed for field in objects.BayModel.fields: diff --git a/magnum/api/controllers/v1/certificate.py b/magnum/api/controllers/v1/certificate.py index d3f5550e28..3ada360395 100644 --- a/magnum/api/controllers/v1/certificate.py +++ b/magnum/api/controllers/v1/certificate.py @@ -48,7 +48,7 @@ class Certificate(base.APIBase): try: self._bay = api_utils.get_resource('Bay', value) self._bay_uuid = self._bay.uuid - except exception.BayNotFound as e: + except exception.ClusterNotFound as e: # Change error code because 404 (NotFound) is inappropriate # response for a POST request to create a Bay e.code = 400 # BadRequest diff --git a/magnum/api/controllers/v1/cluster.py b/magnum/api/controllers/v1/cluster.py new file mode 100644 index 0000000000..26c1c727cf --- /dev/null +++ b/magnum/api/controllers/v1/cluster.py @@ -0,0 +1,468 @@ +# Copyright 2013 UnitedStack Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import uuid + +from oslo_log import log as logging +from oslo_utils import timeutils +import pecan +import wsme +from wsme import types as wtypes + +from magnum.api import attr_validator +from magnum.api.controllers import base +from magnum.api.controllers import link +from magnum.api.controllers.v1 import collection +from magnum.api.controllers.v1 import types +from magnum.api import expose +from magnum.api import utils as api_utils +from magnum.api.validation import validate_bay_properties +from magnum.common import clients +from magnum.common import exception +from magnum.common import name_generator +from magnum.common import policy +from magnum.i18n import _LW +from magnum import objects +from magnum.objects import fields + +LOG = logging.getLogger(__name__) + + +class ClusterPatchType(types.JsonPatchType): + @staticmethod + def mandatory_attrs(): + return ['/cluster_template_id'] + + @staticmethod + def internal_attrs(): + internal_attrs = ['/api_address', '/node_addresses', + '/master_addresses', '/stack_id', + '/ca_cert_ref', '/magnum_cert_ref', + '/trust_id', '/trustee_user_name', + '/trustee_password', '/trustee_user_id'] + return types.JsonPatchType.internal_attrs() + internal_attrs + + +class ClusterID(wtypes.Base): + """API representation of a cluster ID + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of a cluster + ID. + """ + + uuid = types.uuid + """Unique UUID for this cluster""" + + def __init__(self, uuid): + self.uuid = uuid + + +class Cluster(base.APIBase): + """API representation of a cluster. + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of a bay. + """ + + _cluster_template_id = None + + def _get_cluster_template_id(self): + return self._cluster_template_id + + def _set_cluster_template_id(self, value): + if value and self._cluster_template_id != value: + try: + cluster_template = api_utils.get_resource('BayModel', value) + self._cluster_template_id = cluster_template.uuid + except exception.ClusterTemplateNotFound as e: + # Change error code because 404 (NotFound) is inappropriate + # response for a POST request to create a Bay + e.code = 400 # BadRequest + raise + elif value == wtypes.Unset: + self._cluster_template_id = wtypes.Unset + + uuid = types.uuid + """Unique UUID for this cluster""" + + name = wtypes.StringType(min_length=1, max_length=242, + pattern='^[a-zA-Z][a-zA-Z0-9_.-]*$') + """Name of this cluster, max length is limited to 242 because of heat + stack requires max length limit to 255, and Magnum amend a uuid length""" + + cluster_template_id = wsme.wsproperty(wtypes.text, + _get_cluster_template_id, + _set_cluster_template_id, + mandatory=True) + """The cluster_template UUID""" + + node_count = wsme.wsattr(wtypes.IntegerType(minimum=1), default=1) + """The node count for this cluster. Default to 1 if not set""" + + master_count = wsme.wsattr(wtypes.IntegerType(minimum=1), default=1) + """The number of master nodes for this cluster. Default to 1 if not set""" + + create_timeout = wsme.wsattr(wtypes.IntegerType(minimum=0), default=60) + """Timeout for creating the cluster in minutes. Default to 60 if not set""" + + links = wsme.wsattr([link.Link], readonly=True) + """A list containing a self link and associated cluster links""" + + stack_id = wsme.wsattr(wtypes.text, readonly=True) + """Stack id of the heat stack""" + + status = wtypes.Enum(str, *fields.BayStatus.ALL) + """Status of the cluster from the heat stack""" + + status_reason = wtypes.text + """Status reason of the cluster from the heat stack""" + + discovery_url = wtypes.text + """Url used for cluster node discovery""" + + api_address = wsme.wsattr(wtypes.text, readonly=True) + """Api address of cluster master node""" + + node_addresses = wsme.wsattr([wtypes.text], readonly=True) + """IP addresses of cluster agent nodes""" + + master_addresses = wsme.wsattr([wtypes.text], readonly=True) + """IP addresses of cluster master nodes""" + + faults = wsme.wsattr(wtypes.DictType(str, wtypes.text)) + """Fault info collected from the heat resources of this cluster""" + + def __init__(self, **kwargs): + super(Cluster, self).__init__() + self.fields = [] + for field in objects.Bay.fields: + # Skip fields we do not expose. + if not hasattr(self, field): + continue + self.fields.append(field) + setattr(self, field, kwargs.get(field, wtypes.Unset)) + + # Set the renamed attributes for clusters + self.fields.append('cluster_template_id') + if 'cluster_template_id' in kwargs.keys(): + setattr(self, 'cluster_template_id', + kwargs.get('cluster_template_id', wtypes.Unset)) + else: + setattr(self, 'cluster_template_id', kwargs.get('baymodel_id', + wtypes.Unset)) + + self.fields.append('create_timeout') + if 'create_timeout' in kwargs.keys(): + setattr(self, 'create_timeout', kwargs.get('create_timeout', + wtypes.Unset)) + else: + setattr(self, 'create_timeout', kwargs.get('bay_create_timeout', + wtypes.Unset)) + + self.fields.append('faults') + if 'faults' in kwargs.keys(): + setattr(self, 'faults', kwargs.get('faults', wtypes.Unset)) + else: + setattr(self, 'faults', kwargs.get('bay_faults', wtypes.Unset)) + + @staticmethod + def _convert_with_links(cluster, url, expand=True): + if not expand: + cluster.unset_fields_except(['uuid', 'name', 'cluster_template_id', + 'node_count', 'status', + 'create_timeout', 'master_count', + 'stack_id']) + + cluster.links = [link.Link.make_link('self', url, + 'bays', cluster.uuid), + link.Link.make_link('bookmark', url, + 'bays', cluster.uuid, + bookmark=True)] + return cluster + + @classmethod + def convert_with_links(cls, rpc_bay, expand=True): + cluster = Cluster(**rpc_bay.as_dict()) + return cls._convert_with_links(cluster, pecan.request.host_url, expand) + + @classmethod + def sample(cls, expand=True): + temp_id = '4a96ac4b-2447-43f1-8ca6-9fd6f36d146d' + sample = cls(uuid='27e3153e-d5bf-4b7e-b517-fb518e17f34c', + name='example', + cluster_template_id=temp_id, + node_count=2, + master_count=1, + create_timeout=15, + stack_id='49dc23f5-ffc9-40c3-9d34-7be7f9e34d63', + status=fields.BayStatus.CREATE_COMPLETE, + status_reason="CREATE completed successfully", + api_address='172.24.4.3', + node_addresses=['172.24.4.4', '172.24.4.5'], + created_at=timeutils.utcnow(), + updated_at=timeutils.utcnow()) + return cls._convert_with_links(sample, 'http://localhost:9511', expand) + + def as_dict(self): + """Render this object as a dict of its fields.""" + + # Override this for updated cluster values + d = super(Cluster, self).as_dict() + + if 'cluster_template_id' in d.keys(): + d['baymodel_id'] = d['cluster_template_id'] + del d['cluster_template_id'] + + if 'create_timeout' in d.keys(): + d['bay_create_timeout'] = d['create_timeout'] + del d['create_timeout'] + + if 'faults' in d.keys(): + d['bay_faults'] = d['faults'] + del d['faults'] + + return d + + +class ClusterCollection(collection.Collection): + """API representation of a collection of clusters.""" + + clusters = [Cluster] + """A list containing cluster objects""" + + def __init__(self, **kwargs): + self._type = 'clusters' + + @staticmethod + def convert_with_links(rpc_bays, limit, url=None, expand=False, **kwargs): + collection = ClusterCollection() + collection.clusters = [Cluster.convert_with_links(p, expand) + for p in rpc_bays] + collection.next = collection.get_next(limit, url=url, **kwargs) + return collection + + @classmethod + def sample(cls): + sample = cls() + sample.clusters = [Cluster.sample(expand=False)] + return sample + + +class ClustersController(base.Controller): + """REST controller for Clusters.""" + + def __init__(self): + super(ClustersController, self).__init__() + + _custom_actions = { + 'detail': ['GET'], + } + + def _generate_name_for_cluster(self, context): + """Generate a random name like: zeta-22-bay.""" + name_gen = name_generator.NameGenerator() + name = name_gen.generate() + return name + '-cluster' + + def _get_clusters_collection(self, marker, limit, + sort_key, sort_dir, expand=False, + resource_url=None): + + limit = api_utils.validate_limit(limit) + sort_dir = api_utils.validate_sort_dir(sort_dir) + + marker_obj = None + if marker: + marker_obj = objects.Bay.get_by_uuid(pecan.request.context, + marker) + + clusters = objects.Bay.list(pecan.request.context, limit, + marker_obj, sort_key=sort_key, + sort_dir=sort_dir) + + return ClusterCollection.convert_with_links(clusters, limit, + url=resource_url, + expand=expand, + sort_key=sort_key, + sort_dir=sort_dir) + + @expose.expose(ClusterCollection, types.uuid, int, wtypes.text, + wtypes.text) + def get_all(self, marker=None, limit=None, sort_key='id', + sort_dir='asc'): + """Retrieve a list of clusters. + + :param marker: pagination marker for large data sets. + :param limit: maximum number of resources to return in a single result. + :param sort_key: column to sort results by. Default: id. + :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + """ + context = pecan.request.context + policy.enforce(context, 'cluster:get_all', + action='cluster:get_all') + return self._get_clusters_collection(marker, limit, sort_key, + sort_dir) + + @expose.expose(ClusterCollection, types.uuid, int, wtypes.text, + wtypes.text) + def detail(self, marker=None, limit=None, sort_key='id', + sort_dir='asc'): + """Retrieve a list of clusters with detail. + + :param marker: pagination marker for large data sets. + :param limit: maximum number of resources to return in a single result. + :param sort_key: column to sort results by. Default: id. + :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + """ + context = pecan.request.context + policy.enforce(context, 'cluster:detail', + action='cluster:detail') + + # NOTE(lucasagomes): /detail should only work against collections + parent = pecan.request.path.split('/')[:-1][-1] + if parent != "clusters": + raise exception.HTTPNotFound + + expand = True + resource_url = '/'.join(['clusters', 'detail']) + return self._get_clusters_collection(marker, limit, + sort_key, sort_dir, expand, + resource_url) + + def _collect_fault_info(self, context, cluster): + """Collect fault info from heat resources of given cluster + + and store them into cluster.faults. + """ + osc = clients.OpenStackClients(context) + filters = {'status': 'FAILED'} + try: + failed_resources = osc.heat().resources.list( + cluster.stack_id, nested_depth=2, filters=filters) + except Exception as e: + failed_resources = [] + LOG.warning(_LW("Failed to retrieve failed resources for " + "cluster %(cluster)s from Heat stack " + "%(stack)s due to error: %(e)s"), + {'cluster': cluster.uuid, + 'stack': cluster.stack_id, 'e': e}, + exc_info=True) + + return {res.resource_name: res.resource_status_reason + for res in failed_resources} + + @expose.expose(Cluster, types.uuid_or_name) + def get_one(self, bay_ident): + """Retrieve information about the given bay. + + :param bay_ident: UUID of a bay or logical name of the bay. + """ + context = pecan.request.context + cluster = api_utils.get_resource('Bay', bay_ident) + policy.enforce(context, 'cluster:get', cluster, + action='cluster:get') + + cluster = Cluster.convert_with_links(cluster) + + if cluster.status in fields.BayStatus.STATUS_FAILED: + cluster.faults = self._collect_fault_info(context, cluster) + + return cluster + + @expose.expose(ClusterID, body=Cluster, status_code=202) + def post(self, cluster): + """Create a new cluster. + + :param cluster: a cluster within the request body. + """ + context = pecan.request.context + policy.enforce(context, 'cluster:create', + action='cluster:create') + temp_id = cluster.cluster_template_id + cluster_template = objects.BayModel.get_by_uuid(context, temp_id) + cluster_dict = cluster.as_dict() + + attr_validator.validate_os_resources(context, + cluster_template.as_dict()) + attr_validator.validate_master_count(cluster_dict, + cluster_template.as_dict()) + + cluster_dict['project_id'] = context.project_id + cluster_dict['user_id'] = context.user_id + # NOTE(yuywz): We will generate a random human-readable name for + # cluster if the name is not specified by user. + name = cluster_dict.get('name') or \ + self._generate_name_for_cluster(context) + cluster_dict['name'] = name + + new_cluster = objects.Bay(context, **cluster_dict) + new_cluster.uuid = uuid.uuid4() + pecan.request.rpcapi.bay_create_async(new_cluster, + cluster.create_timeout) + + return ClusterID(new_cluster.uuid) + + @wsme.validate(types.uuid, [ClusterPatchType]) + @expose.expose(ClusterID, types.uuid_or_name, body=[ClusterPatchType], + status_code=202) + def patch(self, cluster_ident, patch): + """Update an existing bay. + + :param cluster_ident: UUID or logical name of a bay. + :param patch: a json PATCH document to apply to this bay. + """ + context = pecan.request.context + cluster = api_utils.get_resource('Bay', cluster_ident) + policy.enforce(context, 'cluster:update', cluster, + action='cluster:update') + try: + cluster_dict = cluster.as_dict() + new_cluster = Cluster(**api_utils.apply_jsonpatch(cluster_dict, + patch)) + except api_utils.JSONPATCH_EXCEPTIONS as e: + raise exception.PatchError(patch=patch, reason=e) + + # Update only the fields that have changed + for field in objects.Bay.fields: + try: + patch_val = getattr(new_cluster, field) + except AttributeError: + # Ignore fields that aren't exposed in the API + continue + if patch_val == wtypes.Unset: + patch_val = None + if cluster[field] != patch_val: + cluster[field] = patch_val + + delta = cluster.obj_what_changed() + + validate_bay_properties(delta) + + pecan.request.rpcapi.bay_update_async(cluster) + return ClusterID(cluster.uuid) + + @expose.expose(None, types.uuid_or_name, status_code=204) + def delete(self, cluster_ident): + """Delete a cluster. + + :param cluster_ident: UUID of cluster or logical name of the cluster. + """ + context = pecan.request.context + cluster = api_utils.get_resource('Bay', cluster_ident) + policy.enforce(context, 'cluster:delete', cluster, + action='cluster:delete') + + pecan.request.rpcapi.bay_delete_async(cluster.uuid) diff --git a/magnum/api/controllers/v1/cluster_template.py b/magnum/api/controllers/v1/cluster_template.py new file mode 100644 index 0000000000..e80ab81c81 --- /dev/null +++ b/magnum/api/controllers/v1/cluster_template.py @@ -0,0 +1,434 @@ +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_utils import timeutils +import pecan +import wsme +from wsme import types as wtypes + +from magnum.api import attr_validator +from magnum.api.controllers import base +from magnum.api.controllers import link +from magnum.api.controllers.v1 import collection +from magnum.api.controllers.v1 import types +from magnum.api import expose +from magnum.api import utils as api_utils +from magnum.api import validation +from magnum.common import clients +from magnum.common import exception +from magnum.common import name_generator +from magnum.common import policy +from magnum import objects +from magnum.objects import fields + + +class ClusterTemplatePatchType(types.JsonPatchType): + + @staticmethod + def mandatory_attrs(): + return ['/image_id', '/keypair_id', '/external_network_id', '/coe', + '/tls_disabled', '/public', '/registry_enabled', + '/server_type', '/cluster_distro', '/network_driver'] + + +class ClusterTemplate(base.APIBase): + """API representation of a clustertemplate. + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of + a clustertemplate. + """ + + uuid = types.uuid + """Unique UUID for this clustertemplate""" + + name = wtypes.StringType(min_length=1, max_length=255) + """The name of the clustertemplate""" + + coe = wtypes.Enum(str, *fields.BayType.ALL, mandatory=True) + """The Container Orchestration Engine for this clustertemplate""" + + image_id = wsme.wsattr(wtypes.StringType(min_length=1, max_length=255), + mandatory=True) + """The image name or UUID to use as an image for this clustertemplate""" + + flavor_id = wtypes.StringType(min_length=1, max_length=255) + """The flavor of this clustertemplate""" + + master_flavor_id = wtypes.StringType(min_length=1, max_length=255) + """The flavor of the master node for this clustertemplate""" + + dns_nameserver = wtypes.IPv4AddressType() + """The DNS nameserver address""" + + keypair_id = wsme.wsattr(wtypes.StringType(min_length=1, max_length=255), + mandatory=True) + """The name or id of the nova ssh keypair""" + + external_network_id = wtypes.StringType(min_length=1, max_length=255) + """The external network to attach the cluster""" + + fixed_network = wtypes.StringType(min_length=1, max_length=255) + """The fixed network name to attach the cluster""" + + fixed_subnet = wtypes.StringType(min_length=1, max_length=255) + """The fixed subnet name to attach the cluster""" + + network_driver = wtypes.StringType(min_length=1, max_length=255) + """The name of the driver used for instantiating container networks""" + + apiserver_port = wtypes.IntegerType(minimum=1024, maximum=65535) + """The API server port for k8s""" + + docker_volume_size = wtypes.IntegerType(minimum=1) + """The size in GB of the docker volume""" + + cluster_distro = wtypes.StringType(min_length=1, max_length=255) + """The Cluster distro for the cluster, ex - coreos, fedora-atomic.""" + + links = wsme.wsattr([link.Link], readonly=True) + """A list containing a self link and associated clustertemplate links""" + + http_proxy = wtypes.StringType(min_length=1, max_length=255) + """Address of a proxy that will receive all HTTP requests and relay them. + The format is a URL including a port number. + """ + + https_proxy = wtypes.StringType(min_length=1, max_length=255) + """Address of a proxy that will receive all HTTPS requests and relay them. + The format is a URL including a port number. + """ + + no_proxy = wtypes.StringType(min_length=1, max_length=255) + """A comma separated list of ips for which proxies should not + used in the cluster + """ + + volume_driver = wtypes.StringType(min_length=1, max_length=255) + """The name of the driver used for instantiating container volume driver""" + + registry_enabled = wsme.wsattr(types.boolean, default=False) + """Indicates whether the docker registry is enabled""" + + labels = wtypes.DictType(str, str) + """One or more key/value pairs""" + + tls_disabled = wsme.wsattr(types.boolean, default=False) + """Indicates whether the TLS should be disabled""" + + public = wsme.wsattr(types.boolean, default=False) + """Indicates whether the clustertemplate is public or not.""" + + server_type = wsme.wsattr(wtypes.StringType(min_length=1, + max_length=255), + default='vm') + """Server type for this clustertemplate """ + + insecure_registry = wtypes.StringType(min_length=1, max_length=255) + """insecure registry url when create clustertemplate """ + + docker_storage_driver = wtypes.Enum(str, *fields.DockerStorageDriver.ALL) + """Docker storage driver""" + + master_lb_enabled = wsme.wsattr(types.boolean, default=False) + """Indicates whether created bays should have a load balancer for master + nodes or not. + """ + + floating_ip_enabled = wsme.wsattr(types.boolean, default=True) + """Indicates whether created bays should have a floating ip or not.""" + + def __init__(self, **kwargs): + self.fields = [] + for field in objects.BayModel.fields: + # Skip fields we do not expose. + if not hasattr(self, field): + continue + self.fields.append(field) + setattr(self, field, kwargs.get(field, wtypes.Unset)) + + @staticmethod + def _convert_with_links(cluster_template, url): + cluster_template.links = [link.Link.make_link('self', url, + 'clustertemplates', + cluster_template.uuid), + link.Link.make_link('bookmark', url, + 'clustertemplates', + cluster_template.uuid, + bookmark=True)] + return cluster_template + + @classmethod + def convert_with_links(cls, rpc_baymodel): + cluster_template = ClusterTemplate(**rpc_baymodel.as_dict()) + return cls._convert_with_links(cluster_template, + pecan.request.host_url) + + @classmethod + def sample(cls): + sample = cls( + uuid='27e3153e-d5bf-4b7e-b517-fb518e17f34c', + name='example', + image_id='Fedora-k8s', + flavor_id='m1.small', + master_flavor_id='m1.small', + dns_nameserver='8.8.1.1', + keypair_id='keypair1', + external_network_id='ffc44e4a-2319-4062-bce0-9ae1c38b05ba', + fixed_network='private', + fixed_subnet='private-subnet', + network_driver='libnetwork', + volume_driver='cinder', + apiserver_port=8080, + docker_volume_size=25, + docker_storage_driver='devicemapper', + cluster_distro='fedora-atomic', + coe=fields.BayType.KUBERNETES, + http_proxy='http://proxy.com:123', + https_proxy='https://proxy.com:123', + no_proxy='192.168.0.1,192.168.0.2,192.168.0.3', + labels={'key1': 'val1', 'key2': 'val2'}, + server_type='vm', + insecure_registry='10.238.100.100:5000', + created_at=timeutils.utcnow(), + updated_at=timeutils.utcnow(), + public=False, + master_lb_enabled=False, + floating_ip_enabled=True) + return cls._convert_with_links(sample, 'http://localhost:9511') + + +class ClusterTemplateCollection(collection.Collection): + """API representation of a collection of clustertemplates.""" + + clustertemplates = [ClusterTemplate] + """A list containing clustertemplates objects""" + + def __init__(self, **kwargs): + self._type = 'clustertemplates' + + @staticmethod + def convert_with_links(rpc_baymodels, limit, url=None, **kwargs): + collection = ClusterTemplateCollection() + collection.clustertemplates = [ClusterTemplate.convert_with_links(p) + for p in rpc_baymodels] + collection.next = collection.get_next(limit, url=url, **kwargs) + return collection + + @classmethod + def sample(cls): + sample = cls() + sample.clustertemplates = [ClusterTemplate.sample()] + return sample + + +class ClusterTemplatesController(base.Controller): + """REST controller for ClusterTemplates.""" + + _custom_actions = { + 'detail': ['GET'], + } + + def _generate_name_for_cluster_template(self, context): + """Generate a random name like: zeta-22-model.""" + + name_gen = name_generator.NameGenerator() + name = name_gen.generate() + return name + '-template' + + def _get_cluster_templates_collection(self, marker, limit, + sort_key, sort_dir, + resource_url=None): + + limit = api_utils.validate_limit(limit) + sort_dir = api_utils.validate_sort_dir(sort_dir) + + marker_obj = None + if marker: + marker_obj = objects.BayModel.get_by_uuid(pecan.request.context, + marker) + + cluster_templates = objects.BayModel.list(pecan.request.context, limit, + marker_obj, + sort_key=sort_key, + sort_dir=sort_dir) + + return ClusterTemplateCollection.convert_with_links(cluster_templates, + limit, + url=resource_url, + sort_key=sort_key, + sort_dir=sort_dir) + + @expose.expose(ClusterTemplateCollection, types.uuid, int, wtypes.text, + wtypes.text) + def get_all(self, marker=None, limit=None, sort_key='id', + sort_dir='asc'): + """Retrieve a list of baymodels. + + :param marker: pagination marker for large data sets. + :param limit: maximum number of resources to return in a single result. + :param sort_key: column to sort results by. Default: id. + :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + """ + context = pecan.request.context + policy.enforce(context, 'clustertemplate:get_all', + action='clustertemplate:get_all') + return self._get_cluster_templates_collection(marker, limit, sort_key, + sort_dir) + + @expose.expose(ClusterTemplateCollection, types.uuid, int, wtypes.text, + wtypes.text) + def detail(self, marker=None, limit=None, sort_key='id', + sort_dir='asc'): + """Retrieve a list of clustertemplates with detail. + + :param marker: pagination marker for large data sets. + :param limit: maximum number of resources to return in a single result. + :param sort_key: column to sort results by. Default: id. + :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + """ + context = pecan.request.context + policy.enforce(context, 'clustertemplate:detail', + action='clustertemplate:detail') + + # NOTE(lucasagomes): /detail should only work against collections + parent = pecan.request.path.split('/')[:-1][-1] + if parent != "clustertemplates": + raise exception.HTTPNotFound + + resource_url = '/'.join(['clustertemplates', 'detail']) + return self._get_cluster_templates_collection(marker, limit, + sort_key, sort_dir, + resource_url) + + @expose.expose(ClusterTemplate, types.uuid_or_name) + def get_one(self, cluster_template_ident): + """Retrieve information about the given clustertemplate. + + :param cluster_template_ident: UUID or logical name of a + clustertemplate. + """ + context = pecan.request.context + cluster_template = api_utils.get_resource('BayModel', + cluster_template_ident) + if not cluster_template.public: + policy.enforce(context, 'clustertemplate:get', cluster_template, + action='clustertemplate:get') + + return ClusterTemplate.convert_with_links(cluster_template) + + @expose.expose(ClusterTemplate, body=ClusterTemplate, status_code=201) + @validation.enforce_network_driver_types_create() + @validation.enforce_volume_driver_types_create() + @validation.enforce_volume_storage_size_create() + def post(self, cluster_template): + """Create a new cluster_template. + + :param cluster_template: a cluster_template within the request body. + """ + context = pecan.request.context + policy.enforce(context, 'clustertemplate:create', + action='clustertemplate:create') + cluster_template_dict = cluster_template.as_dict() + cli = clients.OpenStackClients(context) + attr_validator.validate_os_resources(context, cluster_template_dict) + image_data = attr_validator.validate_image(cli, + cluster_template_dict[ + 'image_id']) + cluster_template_dict['cluster_distro'] = image_data['os_distro'] + cluster_template_dict['project_id'] = context.project_id + cluster_template_dict['user_id'] = context.user_id + # check permissions for making cluster_template public + if cluster_template_dict['public']: + if not policy.enforce(context, "clustertemplate:publish", None, + do_raise=False): + raise exception.ClusterTemplatePublishDenied() + + # NOTE(yuywz): We will generate a random human-readable name for + # cluster_template if the name is not specified by user. + arg_name = cluster_template_dict.get('name') + name = arg_name or self._generate_name_for_cluster_template(context) + cluster_template_dict['name'] = name + + new_cluster_template = objects.BayModel(context, + **cluster_template_dict) + new_cluster_template.create() + # Set the HTTP Location Header + pecan.response.location = link.build_url('clustertemplates', + new_cluster_template.uuid) + return ClusterTemplate.convert_with_links(new_cluster_template) + + @wsme.validate(types.uuid_or_name, [ClusterTemplatePatchType]) + @expose.expose(ClusterTemplate, types.uuid_or_name, + body=[ClusterTemplatePatchType]) + @validation.enforce_network_driver_types_update() + @validation.enforce_volume_driver_types_update() + def patch(self, cluster_template_ident, patch): + """Update an existing cluster_template. + + :param cluster_template_ident: UUID or logic name of a + cluster_template. + :param patch: a json PATCH document to apply to this + cluster_template. + """ + context = pecan.request.context + cluster_template = api_utils.get_resource('BayModel', + cluster_template_ident) + policy.enforce(context, 'clustertemplate:update', cluster_template, + action='clustertemplate:update') + try: + cluster_template_dict = cluster_template.as_dict() + new_cluster_template = ClusterTemplate(**api_utils.apply_jsonpatch( + cluster_template_dict, + patch)) + except api_utils.JSONPATCH_EXCEPTIONS as e: + raise exception.PatchError(patch=patch, reason=e) + + new_cluster_template_dict = new_cluster_template.as_dict() + attr_validator.validate_os_resources(context, + new_cluster_template_dict) + # check permissions when updating baymodel public flag + if cluster_template.public != new_cluster_template.public: + if not policy.enforce(context, "clustertemplate:publish", None, + do_raise=False): + raise exception.ClusterTemplatePublishDenied() + + # Update only the fields that have changed + for field in objects.BayModel.fields: + try: + patch_val = getattr(new_cluster_template, field) + except AttributeError: + # Ignore fields that aren't exposed in the API + continue + if patch_val == wtypes.Unset: + patch_val = None + if cluster_template[field] != patch_val: + cluster_template[field] = patch_val + + cluster_template.save() + return ClusterTemplate.convert_with_links(cluster_template) + + @expose.expose(None, types.uuid_or_name, status_code=204) + def delete(self, cluster_template_ident): + """Delete a cluster_template. + + :param cluster_template_ident: UUID or logical name of a + cluster_template. + """ + context = pecan.request.context + cluster_template = api_utils.get_resource('BayModel', + cluster_template_ident) + policy.enforce(context, 'clustertemplate:delete', cluster_template, + action='clustertemplate:delete') + cluster_template.destroy() diff --git a/magnum/common/exception.py b/magnum/common/exception.py index 84819ec949..ee5f276bf9 100644 --- a/magnum/common/exception.py +++ b/magnum/common/exception.py @@ -229,28 +229,29 @@ class FileSystemNotSupported(MagnumException): "File system %(fs)s is not supported.") -class BayModelNotFound(ResourceNotFound): - message = _("Baymodel %(baymodel)s could not be found.") +class ClusterTemplateNotFound(ResourceNotFound): + message = _("ClusterTemplate %(clustertemplate)s could not be found.") -class BayModelAlreadyExists(Conflict): - message = _("A baymodel with UUID %(uuid)s already exists.") +class ClusterTemplateAlreadyExists(Conflict): + message = _("A ClusterTemplate with UUID %(uuid)s already exists.") -class BayModelReferenced(Invalid): - message = _("Baymodel %(baymodel)s is referenced by one or multiple bays.") +class ClusterTemplateReferenced(Invalid): + message = _("ClusterTemplate %(clustertemplate)s is referenced by one or" + " multiple clusters.") -class BaymodelPublishDenied(NotAuthorized): - message = _("Not authorized to set public flag for baymodel.") +class ClusterTemplatePublishDenied(NotAuthorized): + message = _("Not authorized to set public flag for cluster template.") -class BayNotFound(ResourceNotFound): - message = _("Bay %(bay)s could not be found.") +class ClusterNotFound(ResourceNotFound): + message = _("Cluster %(cluster)s could not be found.") -class BayAlreadyExists(Conflict): - message = _("A bay with UUID %(uuid)s already exists.") +class ClusterAlreadyExists(Conflict): + message = _("A cluster with UUID %(uuid)s already exists.") class ContainerNotFound(ResourceNotFound): diff --git a/magnum/conductor/handlers/bay_conductor.py b/magnum/conductor/handlers/bay_conductor.py index d5fd50766b..c7856c9e1c 100644 --- a/magnum/conductor/handlers/bay_conductor.py +++ b/magnum/conductor/handlers/bay_conductor.py @@ -234,7 +234,7 @@ class Handler(object): trust_manager.delete_trustee_and_trust(osc, context, bay) cert_manager.delete_certificates_from_bay(bay, context=context) bay.destroy() - except exception.BayNotFound: + except exception.ClusterNotFound: LOG.info(_LI('The bay %s has been deleted by others.'), uuid) conductor_utils.notify_about_bay_operation( context, taxonomy.ACTION_DELETE, taxonomy.OUTCOME_SUCCESS) @@ -348,7 +348,7 @@ class HeatPoller(object): cert_manager.delete_certificates_from_bay(self.bay, context=self.context) self.bay.destroy() - except exception.BayNotFound: + except exception.ClusterNotFound: LOG.info(_LI('The bay %s has been deleted by others.') % self.bay.uuid) diff --git a/magnum/db/api.py b/magnum/db/api.py index b201fddd24..9cfcb29b4d 100644 --- a/magnum/db/api.py +++ b/magnum/db/api.py @@ -118,7 +118,7 @@ class Connection(object): :param bay_id: The id or uuid of a bay. :returns: A bay. - :raises: BayNotFound + :raises: ClusterNotFound """ @abc.abstractmethod @@ -201,7 +201,7 @@ class Connection(object): :param baymodel_id: The id or uuid of a baymodel. :returns: A baymodel. - :raises: BayModelNotFound + :raises: ClusterTemplateNotFound """ @abc.abstractmethod diff --git a/magnum/db/sqlalchemy/api.py b/magnum/db/sqlalchemy/api.py index bdfaac1706..98701e6445 100644 --- a/magnum/db/sqlalchemy/api.py +++ b/magnum/db/sqlalchemy/api.py @@ -157,7 +157,7 @@ class Connection(api.Connection): try: bay.save() except db_exc.DBDuplicateEntry: - raise exception.BayAlreadyExists(uuid=values['uuid']) + raise exception.ClusterAlreadyExists(uuid=values['uuid']) return bay def get_bay_by_id(self, context, bay_id): @@ -167,7 +167,7 @@ class Connection(api.Connection): try: return query.one() except NoResultFound: - raise exception.BayNotFound(bay=bay_id) + raise exception.ClusterNotFound(cluster=bay_id) def get_bay_by_name(self, context, bay_name): query = model_query(models.Bay) @@ -179,7 +179,7 @@ class Connection(api.Connection): raise exception.Conflict('Multiple bays exist with same name.' ' Please use the bay uuid instead.') except NoResultFound: - raise exception.BayNotFound(bay=bay_name) + raise exception.ClusterNotFound(cluster=bay_name) def get_bay_by_uuid(self, context, bay_uuid): query = model_query(models.Bay) @@ -188,7 +188,7 @@ class Connection(api.Connection): try: return query.one() except NoResultFound: - raise exception.BayNotFound(bay=bay_uuid) + raise exception.ClusterNotFound(cluster=bay_uuid) def destroy_bay(self, bay_id): session = get_session() @@ -199,7 +199,7 @@ class Connection(api.Connection): try: query.one() except NoResultFound: - raise exception.BayNotFound(bay=bay_id) + raise exception.ClusterNotFound(cluster=bay_id) query.delete() @@ -219,7 +219,7 @@ class Connection(api.Connection): try: ref = query.with_lockmode('update').one() except NoResultFound: - raise exception.BayNotFound(bay=bay_id) + raise exception.ClusterNotFound(cluster=bay_id) if 'provision_state' in values: values['provision_updated_at'] = timeutils.utcnow() @@ -264,7 +264,7 @@ class Connection(api.Connection): try: baymodel.save() except db_exc.DBDuplicateEntry: - raise exception.BayModelAlreadyExists(uuid=values['uuid']) + raise exception.ClusterTemplateAlreadyExists(uuid=values['uuid']) return baymodel def get_baymodel_by_id(self, context, baymodel_id): @@ -276,7 +276,8 @@ class Connection(api.Connection): try: return query.one() except NoResultFound: - raise exception.BayModelNotFound(baymodel=baymodel_id) + raise exception.ClusterTemplateNotFound( + clustertemplate=baymodel_id) def get_baymodel_by_uuid(self, context, baymodel_uuid): query = model_query(models.BayModel) @@ -287,7 +288,8 @@ class Connection(api.Connection): try: return query.one() except NoResultFound: - raise exception.BayModelNotFound(baymodel=baymodel_uuid) + raise exception.ClusterTemplateNotFound( + clustertemplate=baymodel_uuid) def get_baymodel_by_name(self, context, baymodel_name): query = model_query(models.BayModel) @@ -301,7 +303,8 @@ class Connection(api.Connection): raise exception.Conflict('Multiple baymodels exist with same name.' ' Please use the baymodel uuid instead.') except NoResultFound: - raise exception.BayModelNotFound(baymodel=baymodel_name) + raise exception.ClusterTemplateNotFound( + clustertemplate=baymodel_name) def _is_baymodel_referenced(self, session, baymodel_uuid): """Checks whether the baymodel is referenced by bay(s).""" @@ -324,10 +327,12 @@ class Connection(api.Connection): try: baymodel_ref = query.one() except NoResultFound: - raise exception.BayModelNotFound(baymodel=baymodel_id) + raise exception.ClusterTemplateNotFound( + clustertemplate=baymodel_id) if self._is_baymodel_referenced(session, baymodel_ref['uuid']): - raise exception.BayModelReferenced(baymodel=baymodel_id) + raise exception.ClusterTemplateReferenced( + clustertemplate=baymodel_id) query.delete() @@ -347,12 +352,14 @@ class Connection(api.Connection): try: ref = query.with_lockmode('update').one() except NoResultFound: - raise exception.BayModelNotFound(baymodel=baymodel_id) + raise exception.ClusterTemplateNotFound( + clustertemplate=baymodel_id) if self._is_baymodel_referenced(session, ref['uuid']): # we only allow to update baymodel to be public if not self._is_publishing_baymodel(values): - raise exception.BayModelReferenced(baymodel=baymodel_id) + raise exception.ClusterTemplateReferenced( + clustertemplate=baymodel_id) ref.update(values) return ref diff --git a/magnum/service/periodic.py b/magnum/service/periodic.py index 338380b316..b5f53e7ef4 100644 --- a/magnum/service/periodic.py +++ b/magnum/service/periodic.py @@ -165,7 +165,7 @@ class MagnumPeriodicTasks(periodic_task.PeriodicTasks): def _sync_deleted_stack(self, bay): try: bay.destroy() - except exception.BayNotFound: + except exception.ClusterNotFound: LOG.info(_LI('The bay %s has been deleted by others.'), bay.uuid) else: LOG.info(_LI("Bay with id %(id)s not found in heat " diff --git a/magnum/tests/functional/api/v1/clients/cluster_client.py b/magnum/tests/functional/api/v1/clients/cluster_client.py new file mode 100644 index 0000000000..9b39c9da4c --- /dev/null +++ b/magnum/tests/functional/api/v1/clients/cluster_client.py @@ -0,0 +1,176 @@ +# 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 oslo_log import log as logging +from tempest.lib import exceptions + +from magnum.i18n import _LE +from magnum.i18n import _LI +from magnum.i18n import _LW +from magnum.tests.functional.api.v1.models import cluster_id_model +from magnum.tests.functional.api.v1.models import cluster_model +from magnum.tests.functional.common import client +from magnum.tests.functional.common import utils + + +class ClusterClient(client.MagnumClient): + """Encapsulates REST calls and maps JSON to/from models""" + + LOG = logging.getLogger(__name__) + + @classmethod + def clusters_uri(cls, filters=None): + """Construct clusters uri with optional filters + + :param filters: Optional k:v dict that's converted to url query + :returns: url string + """ + + url = "/clusters" + if filters: + url = cls.add_filters(url, filters) + return url + + @classmethod + def cluster_uri(cls, cluster_id): + """Construct cluster uri + + :param cluster_id: cluster uuid or name + :returns: url string + """ + + return "{0}/{1}".format(cls.clusters_uri(), cluster_id) + + def list_clusters(self, filters=None, **kwargs): + """Makes GET /clusters request and returns ClusterCollection + + Abstracts REST call to return all clusters + + :param filters: Optional k:v dict that's converted to url query + :returns: response object and ClusterCollection object + """ + + resp, body = self.get(self.clusters_uri(filters), **kwargs) + return self.deserialize(resp, body, cluster_model.ClusterCollection) + + def get_cluster(self, cluster_id, **kwargs): + """Makes GET /cluster request and returns ClusterEntity + + Abstracts REST call to return a single cluster based on uuid or name + + :param cluster_id: cluster uuid or name + :returns: response object and ClusterCollection object + """ + + resp, body = self.get(self.cluster_uri(cluster_id)) + return self.deserialize(resp, body, cluster_model.ClusterEntity) + + def post_cluster(self, model, **kwargs): + """Makes POST /cluster request and returns ClusterIdEntity + + Abstracts REST call to create new cluster + + :param model: ClusterEntity + :returns: response object and ClusterIdEntity object + """ + + resp, body = self.post( + self.clusters_uri(), + body=model.to_json(), **kwargs) + return self.deserialize(resp, body, cluster_id_model.ClusterIdEntity) + + def patch_cluster(self, cluster_id, clusterpatch_listmodel, **kwargs): + """Makes PATCH /cluster request and returns ClusterIdEntity + + Abstracts REST call to update cluster attributes + + :param cluster_id: UUID of cluster + :param clusterpatch_listmodel: ClusterPatchCollection + :returns: response object and ClusterIdEntity object + """ + + resp, body = self.patch( + self.cluster_uri(cluster_id), + body=clusterpatch_listmodel.to_json(), **kwargs) + return self.deserialize(resp, body, cluster_id_model.ClusterIdEntity) + + def delete_cluster(self, cluster_id, **kwargs): + """Makes DELETE /cluster request and returns response object + + Abstracts REST call to delete cluster based on uuid or name + + :param cluster_id: UUID or name of cluster + :returns: response object + """ + + return self.delete(self.cluster_uri(cluster_id), **kwargs) + + def wait_for_cluster_to_delete(self, cluster_id): + utils.wait_for_condition( + lambda: self.does_cluster_not_exist(cluster_id), 10, 600) + + def wait_for_created_cluster(self, cluster_id, delete_on_error=True): + try: + utils.wait_for_condition( + lambda: self.does_cluster_exist(cluster_id), 10, 1800) + except Exception: + # In error state. Clean up the cluster id if desired + self.LOG.error(_LE('Cluster %s entered an exception state.') % + cluster_id) + if delete_on_error: + self.LOG.error(_LE('We will attempt to delete clusters now.')) + self.delete_cluster(cluster_id) + self.wait_for_cluster_to_delete(cluster_id) + raise + + def wait_for_final_state(self, cluster_id): + utils.wait_for_condition( + lambda: self.is_cluster_in_final_state(cluster_id), 10, 1800) + + def is_cluster_in_final_state(self, cluster_id): + try: + resp, model = self.get_cluster(cluster_id) + if model.status in ['CREATED', 'CREATE_COMPLETE', + 'ERROR', 'CREATE_FAILED']: + self.LOG.info(_LI('Cluster %s succeeded.') % cluster_id) + return True + else: + return False + except exceptions.NotFound: + self.LOG.warning(_LW('Cluster %s is not found.') % cluster_id) + return False + + def does_cluster_exist(self, cluster_id): + try: + resp, model = self.get_cluster(cluster_id) + if model.status in ['CREATED', 'CREATE_COMPLETE']: + self.LOG.info(_LI('Cluster %s is created.') % cluster_id) + return True + elif model.status in ['ERROR', 'CREATE_FAILED']: + self.LOG.error(_LE('Cluster %s is in fail state.') % + cluster_id) + raise exceptions.ServerFault( + "Got into an error condition: %s for %s" % + (model.status, cluster_id)) + else: + return False + except exceptions.NotFound: + self.LOG.warning(_LW('Cluster %s is not found.') % cluster_id) + return False + + def does_cluster_not_exist(self, cluster_id): + try: + self.get_cluster(cluster_id) + except exceptions.NotFound: + self.LOG.warning(_LW('Cluster %s is not found.') % cluster_id) + return True + return False diff --git a/magnum/tests/functional/api/v1/clients/cluster_template_client.py b/magnum/tests/functional/api/v1/clients/cluster_template_client.py new file mode 100644 index 0000000000..e3b8e17cca --- /dev/null +++ b/magnum/tests/functional/api/v1/clients/cluster_template_client.py @@ -0,0 +1,113 @@ +# 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 magnum.tests.functional.api.v1.models import cluster_template_model +from magnum.tests.functional.common import client + + +class ClusterTemplateClient(client.MagnumClient): + """Encapsulates REST calls and maps JSON to/from models""" + + @classmethod + def cluster_templates_uri(cls, filters=None): + """Construct clustertemplates uri with optional filters + + :param filters: Optional k:v dict that's converted to url query + :returns: url string + """ + + url = "/clustertemplates" + if filters: + url = cls.add_filters(url, filters) + return url + + @classmethod + def cluster_template_uri(cls, cluster_template_id): + """Construct cluster_template uri + + :param cluster_template_id: cluster_template uuid or name + :returns: url string + """ + + return "{0}/{1}".format(cls.cluster_templates_uri(), + cluster_template_id) + + def list_cluster_templates(self, filters=None, **kwargs): + """Makes GET /clustertemplates request + + Abstracts REST call to return all clustertemplates + + :param filters: Optional k:v dict that's converted to url query + :returns: response object and ClusterTemplateCollection object + """ + + resp, body = self.get(self.cluster_templates_uri(filters), **kwargs) + collection = cluster_template_model.ClusterTemplateCollection + return self.deserialize(resp, body, collection) + + def get_cluster_template(self, cluster_template_id, **kwargs): + """Makes GET /clustertemplate request and returns ClusterTemplateEntity + + Abstracts REST call to return a single clustertempalte based on uuid + or name + + :param cluster_template_id: clustertempalte uuid or name + :returns: response object and ClusterTemplateCollection object + """ + + resp, body = self.get(self.cluster_template_uri(cluster_template_id)) + return self.deserialize(resp, body, + cluster_template_model.ClusterTemplateEntity) + + def post_cluster_template(self, model, **kwargs): + """Makes POST /clustertemplate request + + Abstracts REST call to create new clustertemplate + + :param model: ClusterTemplateEntity + :returns: response object and ClusterTemplateEntity object + """ + + resp, body = self.post( + self.cluster_templates_uri(), + body=model.to_json(), **kwargs) + entity = cluster_template_model.ClusterTemplateEntity + return self.deserialize(resp, body, entity) + + def patch_cluster_template(self, cluster_template_id, + cluster_templatepatch_listmodel, **kwargs): + """Makes PATCH /clustertemplate and returns ClusterTemplateEntity + + Abstracts REST call to update clustertemplate attributes + + :param cluster_template_id: UUID of clustertemplate + :param cluster_templatepatch_listmodel: ClusterTemplatePatchCollection + :returns: response object and ClusterTemplateEntity object + """ + + resp, body = self.patch( + self.cluster_template_uri(cluster_template_id), + body=cluster_templatepatch_listmodel.to_json(), **kwargs) + return self.deserialize(resp, body, + cluster_template_model.ClusterTemplateEntity) + + def delete_cluster_template(self, cluster_template_id, **kwargs): + """Makes DELETE /clustertemplate request and returns response object + + Abstracts REST call to delete clustertemplate based on uuid or name + + :param cluster_template_id: UUID or name of clustertemplate + :returns: response object + """ + + return self.delete(self.cluster_template_uri(cluster_template_id), + **kwargs) diff --git a/magnum/tests/functional/api/v1/models/cluster_id_model.py b/magnum/tests/functional/api/v1/models/cluster_id_model.py new file mode 100644 index 0000000000..d103f971bf --- /dev/null +++ b/magnum/tests/functional/api/v1/models/cluster_id_model.py @@ -0,0 +1,24 @@ +# 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 magnum.tests.functional.common import models + + +class ClusterIdData(models.BaseModel): + """Data that encapsulates ClusterId attributes""" + pass + + +class ClusterIdEntity(models.EntityModel): + """Entity Model that represents a single instance of CertData""" + ENTITY_NAME = 'clusterid' + MODEL_TYPE = ClusterIdData diff --git a/magnum/tests/functional/api/v1/models/cluster_model.py b/magnum/tests/functional/api/v1/models/cluster_model.py new file mode 100644 index 0000000000..af80c946b4 --- /dev/null +++ b/magnum/tests/functional/api/v1/models/cluster_model.py @@ -0,0 +1,30 @@ +# 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 magnum.tests.functional.common import models + + +class ClusterData(models.BaseModel): + """Data that encapsulates cluster attributes""" + pass + + +class ClusterEntity(models.EntityModel): + """Entity Model that represents a single instance of ClusterData""" + ENTITY_NAME = 'cluster' + MODEL_TYPE = ClusterData + + +class ClusterCollection(models.CollectionModel): + """Collection Model that represents a list of ClusterData objects""" + COLLECTION_NAME = 'clusterlists' + MODEL_TYPE = ClusterData diff --git a/magnum/tests/functional/api/v1/models/cluster_template_model.py b/magnum/tests/functional/api/v1/models/cluster_template_model.py new file mode 100644 index 0000000000..4471f1c476 --- /dev/null +++ b/magnum/tests/functional/api/v1/models/cluster_template_model.py @@ -0,0 +1,30 @@ +# 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 magnum.tests.functional.common import models + + +class ClusterTemplateData(models.BaseModel): + """Data that encapsulates clustertemplate attributes""" + pass + + +class ClusterTemplateEntity(models.EntityModel): + """Entity Model that represents a single instance of ClusterTemplateData""" + ENTITY_NAME = 'clustertemplate' + MODEL_TYPE = ClusterTemplateData + + +class ClusterTemplateCollection(models.CollectionModel): + """Collection that represents a list of ClusterTemplateData objects""" + COLLECTION_NAME = 'clustertemplatelists' + MODEL_TYPE = ClusterTemplateData diff --git a/magnum/tests/functional/api/v1/models/cluster_templatepatch_model.py b/magnum/tests/functional/api/v1/models/cluster_templatepatch_model.py new file mode 100644 index 0000000000..83a6b6744b --- /dev/null +++ b/magnum/tests/functional/api/v1/models/cluster_templatepatch_model.py @@ -0,0 +1,77 @@ +# 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 json + +from magnum.tests.functional.common import models + + +class ClusterTemplatePatchData(models.BaseModel): + """Data that encapsulates clustertemplatepatch attributes""" + pass + + +class ClusterTemplatePatchEntity(models.EntityModel): + """Model that represents a single instance of ClusterTemplatePatchData""" + ENTITY_NAME = 'clustertemplatepatch' + MODEL_TYPE = ClusterTemplatePatchData + + +class ClusterTemplatePatchCollection(models.CollectionModel): + """Model that represents a list of ClusterTemplatePatchData objects""" + MODEL_TYPE = ClusterTemplatePatchData + COLLECTION_NAME = 'clustertemplatepatchlist' + + def to_json(self): + """Converts ClusterTemplatePatchCollection to json + + Retrieves list from COLLECTION_NAME attribute and converts each object + to dict, appending it to a list. Then converts the entire list to + json + + This is required due to COLLECTION_NAME holding a list of objects that + needed to be converted to dict individually + + :returns: json object + """ + + data = getattr(self, ClusterTemplatePatchCollection.COLLECTION_NAME) + collection = [] + for d in data: + collection.append(d.to_dict()) + return json.dumps(collection) + + @classmethod + def from_dict(cls, data): + """Converts dict to ClusterTemplatePatchData + + Converts data dict to list of ClusterTemplatePatchData objects and + stores it in COLLECTION_NAME + + Example of dict data: + + [{ + "path": "/name", + "value": "myname", + "op": "replace" + }] + + :param data: dict of patch data + :returns: json object + """ + + model = cls() + collection = [] + for d in data: + collection.append(cls.MODEL_TYPE.from_dict(d)) + setattr(model, cls.COLLECTION_NAME, collection) + return model diff --git a/magnum/tests/functional/api/v1/models/clusterpatch_model.py b/magnum/tests/functional/api/v1/models/clusterpatch_model.py new file mode 100644 index 0000000000..6e93f377af --- /dev/null +++ b/magnum/tests/functional/api/v1/models/clusterpatch_model.py @@ -0,0 +1,76 @@ +# 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 json + +from magnum.tests.functional.common import models + + +class ClusterPatchData(models.BaseModel): + """Data that encapsulates clusterpatch attributes""" + pass + + +class ClusterPatchEntity(models.EntityModel): + """Entity Model that represents a single instance of ClusterPatchData""" + ENTITY_NAME = 'clusterpatch' + MODEL_TYPE = ClusterPatchData + + +class ClusterPatchCollection(models.CollectionModel): + """Collection Model that represents a list of ClusterPatchData objects""" + MODEL_TYPE = ClusterPatchData + COLLECTION_NAME = 'clusterpatchlist' + + def to_json(self): + """Converts ClusterPatchCollection to json + + Retrieves list from COLLECTION_NAME attribute and converts each object + to dict, appending it to a list. Then converts the entire list to json + + This is required due to COLLECTION_NAME holding a list of objects that + needed to be converted to dict individually + + :returns: json object + """ + + data = getattr(self, ClusterPatchCollection.COLLECTION_NAME) + collection = [] + for d in data: + collection.append(d.to_dict()) + return json.dumps(collection) + + @classmethod + def from_dict(cls, data): + """Converts dict to ClusterPatchData + + Converts data dict to list of ClusterPatchData objects and stores it + in COLLECTION_NAME + + Example of dict data: + + [{ + "path": "/name", + "value": "myname", + "op": "replace" + }] + + :param data: dict of patch data + :returns: json object + """ + + model = cls() + collection = [] + for d in data: + collection.append(cls.MODEL_TYPE.from_dict(d)) + setattr(model, cls.COLLECTION_NAME, collection) + return model diff --git a/magnum/tests/functional/api/v1/test_bay.py b/magnum/tests/functional/api/v1/test_bay.py index 7827d08aa7..33cd8a5270 100644 --- a/magnum/tests/functional/api/v1/test_bay.py +++ b/magnum/tests/functional/api/v1/test_bay.py @@ -18,7 +18,6 @@ from tempest.lib.common.utils import data_utils from tempest.lib import exceptions import testtools -from magnum.objects.fields import BayStatus from magnum.tests.functional.api import base from magnum.tests.functional.common import config from magnum.tests.functional.common import datagen @@ -131,56 +130,6 @@ class BayTest(base.BaseTempestTest): resp, model = self.bay_client.get_bay(bay_id) return resp, model - # (dimtruck) Combining all these tests in one because - # they time out on the gate (2 hours not enough) - @testtools.testcase.attr('positive') - def test_create_list_and_delete_bays(self): - gen_model = datagen.valid_bay_data( - baymodel_id=self.baymodel.uuid, node_count=1) - - # test bay create - _, temp_model = self._create_bay(gen_model) - self.assertEqual(BayStatus.CREATE_IN_PROGRESS, temp_model.status) - self.assertIsNone(temp_model.status_reason) - - # test bay list - resp, model = self.bay_client.list_bays() - self.assertEqual(200, resp.status) - self.assertGreater(len(model.bays), 0) - self.assertIn( - temp_model.uuid, list([x['uuid'] for x in model.bays])) - - # test invalid bay update - patch_model = datagen.bay_name_patch_data() - self.assertRaises( - exceptions.BadRequest, - self.bay_client.patch_bay, - temp_model.uuid, patch_model) - - # test bay delete - self._delete_bay(temp_model.uuid) - self.bays.remove(temp_model.uuid) - - @testtools.testcase.attr('positive') - def test_create_delete_bays_async(self): - gen_model = datagen.valid_bay_data( - baymodel_id=self.baymodel.uuid, node_count=1) - - # test bay create - _, temp_model = self._create_bay(gen_model, is_async=True) - self.assertNotIn('status', temp_model) - - # test bay list - resp, model = self.bay_client.list_bays() - self.assertEqual(200, resp.status) - self.assertGreater(len(model.bays), 0) - self.assertIn( - temp_model.uuid, list([x['uuid'] for x in model.bays])) - - # test bay delete - self._delete_bay(temp_model.uuid) - self.bays.remove(temp_model.uuid) - @testtools.testcase.attr('negative') def test_create_bay_for_nonexisting_baymodel(self): gen_model = datagen.valid_bay_data(baymodel_id='this-does-not-exist') @@ -265,37 +214,3 @@ class BayTest(base.BaseTempestTest): self.assertRaises( exceptions.NotFound, self.bay_client.delete_bay, data_utils.rand_uuid()) - - @testtools.testcase.attr('positive') - def test_certificate_sign_and_show(self): - first_model = datagen.valid_bay_data(baymodel_id=self.baymodel.uuid, - name='test') - _, bay_model = self._create_bay(first_model) - - # test ca show - resp, model = self.cert_client.get_cert( - bay_model.uuid) - self.LOG.debug("cert resp: %s" % resp) - self.assertEqual(200, resp.status) - self.assertEqual(model.bay_uuid, bay_model.uuid) - self.assertIsNotNone(model.pem) - self.assertIn('-----BEGIN CERTIFICATE-----', model.pem) - self.assertIn('-----END CERTIFICATE-----', model.pem) - - # test ca sign - model = datagen.cert_data(bay_uuid=bay_model.uuid) - resp, model = self.cert_client.post_cert(model) - self.LOG.debug("cert resp: %s" % resp) - self.assertEqual(201, resp.status) - self.assertEqual(model.bay_uuid, bay_model.uuid) - self.assertIsNotNone(model.pem) - self.assertIn('-----BEGIN CERTIFICATE-----', model.pem) - self.assertIn('-----END CERTIFICATE-----', model.pem) - - # test ca sign invalid - model = datagen.cert_data(bay_uuid=bay_model.uuid, - csr_data="invalid_csr") - self.assertRaises( - exceptions.BadRequest, - self.cert_client.post_cert, - model) diff --git a/magnum/tests/functional/api/v1/test_cluster.py b/magnum/tests/functional/api/v1/test_cluster.py new file mode 100644 index 0000000000..9c943628f6 --- /dev/null +++ b/magnum/tests/functional/api/v1/test_cluster.py @@ -0,0 +1,240 @@ +# 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 fixtures + +from oslo_log import log as logging +from oslo_utils import uuidutils +from tempest.lib.common.utils import data_utils +from tempest.lib import exceptions +import testtools + +from magnum.tests.functional.api import base +from magnum.tests.functional.common import config +from magnum.tests.functional.common import datagen + + +class ClusterTest(base.BaseTempestTest): + + """Tests for cluster CRUD.""" + + LOG = logging.getLogger(__name__) + + def __init__(self, *args, **kwargs): + super(ClusterTest, self).__init__(*args, **kwargs) + self.clusters = [] + self.creds = None + self.keypair = None + self.cluster_template = None + self.cluster_template_client = None + self.keypairs_client = None + self.cluster_client = None + self.cert_client = None + + def setUp(self): + try: + super(ClusterTest, self).setUp() + (self.creds, self.keypair) = self.get_credentials_with_keypair( + type_of_creds='default') + (self.cluster_template_client, + self.keypairs_client) = self.get_clients_with_existing_creds( + creds=self.creds, + type_of_creds='default', + request_type='cluster_template') + (self.cluster_client, _) = self.get_clients_with_existing_creds( + creds=self.creds, + type_of_creds='default', + request_type='cluster') + (self.cert_client, _) = self.get_clients_with_existing_creds( + creds=self.creds, + type_of_creds='default', + request_type='cert') + model = datagen.valid_swarm_cluster_template() + _, self.cluster_template = self._create_cluster_template(model) + + # NOTE (dimtruck) by default tempest sets timeout to 20 mins. + # We need more time. + test_timeout = 1800 + self.useFixture(fixtures.Timeout(test_timeout, gentle=True)) + except Exception: + self.tearDown() + raise + + def tearDown(self): + try: + cluster_list = self.clusters[:] + for cluster_id in cluster_list: + self._delete_cluster(cluster_id) + self.clusters.remove(cluster_id) + if self.cluster_template: + self._delete_cluster_template(self.cluster_template.uuid) + finally: + super(ClusterTest, self).tearDown() + + def _create_cluster_template(self, cm_model): + self.LOG.debug('We will create a clustertemplate for %s' % cm_model) + resp, model = self.cluster_template_client.post_cluster_template( + cm_model) + return resp, model + + def _delete_cluster_template(self, cm_id): + self.LOG.debug('We will delete a clustertemplate for %s' % cm_id) + resp, model = self.cluster_template_client.delete_cluster_template( + cm_id) + return resp, model + + def _create_cluster(self, cluster_model): + self.LOG.debug('We will create cluster for %s' % cluster_model) + resp, model = self.cluster_client.post_cluster(cluster_model) + self.LOG.debug('Response: %s' % resp) + self.assertEqual(202, resp.status) + self.assertIsNotNone(model.uuid) + self.assertTrue(uuidutils.is_uuid_like(model.uuid)) + self.clusters.append(model.uuid) + self.cluster_uuid = model.uuid + if config.Config.copy_logs: + self.addOnException(self.copy_logs_handler( + lambda: list( + [self._get_cluster_by_id(model.uuid)[1].master_addresses, + self._get_cluster_by_id(model.uuid)[1].node_addresses]), + self.cluster_template.coe, + self.keypair)) + self.cluster_client.wait_for_created_cluster(model.uuid, + delete_on_error=False) + return resp, model + + def _delete_cluster(self, cluster_id): + self.LOG.debug('We will delete a cluster for %s' % cluster_id) + resp, model = self.cluster_client.delete_cluster(cluster_id) + self.assertEqual(204, resp.status) + self.cluster_client.wait_for_cluster_to_delete(cluster_id) + self.assertRaises(exceptions.NotFound, self.cert_client.get_cert, + cluster_id) + return resp, model + + def _get_cluster_by_id(self, cluster_id): + resp, model = self.cluster_client.get_cluster(cluster_id) + return resp, model + + # (dimtruck) Combining all these tests in one because + # they time out on the gate (2 hours not enough) + @testtools.testcase.attr('positive') + def test_create_list_sign_delete_clusters(self): + gen_model = datagen.valid_cluster_data( + cluster_template_id=self.cluster_template.uuid, node_count=1) + + # test cluster create + _, cluster_model = self._create_cluster(gen_model) + self.assertNotIn('status', cluster_model) + + # test cluster list + resp, cluster_list_model = self.cluster_client.list_clusters() + self.assertEqual(200, resp.status) + self.assertGreater(len(cluster_list_model.clusters), 0) + self.assertIn( + cluster_model.uuid, list([x['uuid'] + for x in cluster_list_model.clusters])) + + # test invalid cluster update + patch_model = datagen.cluster_name_patch_data() + self.assertRaises( + exceptions.BadRequest, + self.cluster_client.patch_cluster, + cluster_model.uuid, patch_model) + + # test ca show + resp, cert_model = self.cert_client.get_cert( + cluster_model.uuid) + self.LOG.debug("cert resp: %s" % resp) + self.assertEqual(200, resp.status) + self.assertEqual(cert_model.bay_uuid, cluster_model.uuid) + self.assertIsNotNone(cert_model.pem) + self.assertIn('-----BEGIN CERTIFICATE-----', cert_model.pem) + self.assertIn('-----END CERTIFICATE-----', cert_model.pem) + + # test ca sign + cert_data_model = datagen.cert_data(cluster_model.uuid) + resp, cert_model = self.cert_client.post_cert(cert_data_model) + self.LOG.debug("cert resp: %s" % resp) + self.assertEqual(201, resp.status) + self.assertEqual(cert_model.bay_uuid, cluster_model.uuid) + self.assertIsNotNone(cert_model.pem) + self.assertIn('-----BEGIN CERTIFICATE-----', cert_model.pem) + self.assertIn('-----END CERTIFICATE-----', cert_model.pem) + + # test ca sign invalid + cert_data_model = datagen.cert_data(cluster_model.uuid, + csr_data="invalid_csr") + self.assertRaises( + exceptions.BadRequest, + self.cert_client.post_cert, + cert_data_model) + + # test cluster delete + self._delete_cluster(cluster_model.uuid) + self.clusters.remove(cluster_model.uuid) + + @testtools.testcase.attr('negative') + def test_create_cluster_for_nonexisting_cluster_template(self): + cm_id = 'this-does-not-exist' + gen_model = datagen.valid_cluster_data(cluster_template_id=cm_id) + self.assertRaises( + exceptions.BadRequest, + self.cluster_client.post_cluster, gen_model) + + @testtools.testcase.attr('negative') + def test_create_cluster_with_node_count_0(self): + gen_model = datagen.valid_cluster_data( + cluster_template_id=self.cluster_template.uuid, node_count=0) + self.assertRaises( + exceptions.BadRequest, + self.cluster_client.post_cluster, gen_model) + + @testtools.testcase.attr('negative') + def test_create_cluster_with_zero_masters(self): + uuid = self.cluster_template.uuid + gen_model = datagen.valid_cluster_data(cluster_template_id=uuid, + master_count=0) + self.assertRaises( + exceptions.BadRequest, + self.cluster_client.post_cluster, gen_model) + + @testtools.testcase.attr('negative') + def test_create_cluster_with_nonexisting_flavor(self): + gen_model = \ + datagen.cluster_template_data_with_valid_keypair_image_flavor() + resp, cluster_template = self._create_cluster_template(gen_model) + self.assertEqual(201, resp.status) + self.assertIsNotNone(cluster_template.uuid) + + uuid = cluster_template.uuid + gen_model = datagen.valid_cluster_data(cluster_template_id=uuid) + gen_model.flavor_id = 'aaa' + self.assertRaises(exceptions.BadRequest, + self.cluster_client.post_cluster, gen_model) + + resp, _ = self._delete_cluster_template(cluster_template.uuid) + self.assertEqual(204, resp.status) + + @testtools.testcase.attr('negative') + def test_update_cluster_for_nonexisting_cluster(self): + patch_model = datagen.cluster_name_patch_data() + + self.assertRaises( + exceptions.NotFound, + self.cluster_client.patch_cluster, 'fooo', patch_model) + + @testtools.testcase.attr('negative') + def test_delete_cluster_for_nonexisting_cluster(self): + self.assertRaises( + exceptions.NotFound, + self.cluster_client.delete_cluster, data_utils.rand_uuid()) diff --git a/magnum/tests/functional/api/v1/test_cluster_template.py b/magnum/tests/functional/api/v1/test_cluster_template.py new file mode 100644 index 0000000000..79b5a922a7 --- /dev/null +++ b/magnum/tests/functional/api/v1/test_cluster_template.py @@ -0,0 +1,239 @@ +# 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 tempest.lib.common.utils import data_utils +from tempest.lib import exceptions +import testtools + +from magnum.tests.functional.api import base +from magnum.tests.functional.common import datagen + + +class ClusterTemplateTest(base.BaseTempestTest): + + """Tests for clustertemplate CRUD.""" + + def __init__(self, *args, **kwargs): + super(ClusterTemplateTest, self).__init__(*args, **kwargs) + self.cluster_templates = [] + self.cluster_template_client = None + self.keypairs_client = None + + def setUp(self): + try: + super(ClusterTemplateTest, self).setUp() + (self.cluster_template_client, + self.keypairs_client) = self.get_clients_with_new_creds( + type_of_creds='default', + request_type='cluster_template') + except Exception: + self.tearDown() + raise + + def tearDown(self): + for cluster_template_id in self.cluster_templates: + self._delete_cluster_template(cluster_template_id) + self.cluster_templates.remove(cluster_template_id) + super(ClusterTemplateTest, self).tearDown() + + def _create_cluster_template(self, cmodel_model): + resp, model = \ + self.cluster_template_client.post_cluster_template(cmodel_model) + self.assertEqual(201, resp.status) + self.cluster_templates.append(model.uuid) + return resp, model + + def _delete_cluster_template(self, model_id): + resp, model = \ + self.cluster_template_client.delete_cluster_template(model_id) + self.assertEqual(204, resp.status) + return resp, model + + @testtools.testcase.attr('positive') + def test_list_cluster_templates(self): + gen_model = \ + datagen.cluster_template_data_with_valid_keypair_image_flavor() + _, temp_model = self._create_cluster_template(gen_model) + resp, model = self.cluster_template_client.list_cluster_templates() + self.assertEqual(200, resp.status) + self.assertGreater(len(model.clustertemplates), 0) + self.assertIn( + temp_model.uuid, + list([x['uuid'] for x in model.clustertemplates])) + + @testtools.testcase.attr('positive') + def test_create_cluster_template(self): + gen_model = \ + datagen.cluster_template_data_with_valid_keypair_image_flavor() + resp, model = self._create_cluster_template(gen_model) + + @testtools.testcase.attr('positive') + def test_create_get_public_cluster_template(self): + gen_model = datagen.valid_swarm_cluster_template(is_public=True) + resp, model = self._create_cluster_template(gen_model) + + resp, model = \ + self.cluster_template_client.get_cluster_template(model.uuid) + self.assertEqual(200, resp.status) + self.assertTrue(model.public) + + @testtools.testcase.attr('positive') + def test_update_cluster_template_public_by_uuid(self): + path = "/public" + gen_model = \ + datagen.cluster_template_data_with_valid_keypair_image_flavor() + resp, old_model = self._create_cluster_template(gen_model) + + patch_model = datagen.cluster_template_replace_patch_data(path, + value=True) + resp, new_model = self.cluster_template_client.patch_cluster_template( + old_model.uuid, patch_model) + self.assertEqual(200, resp.status) + + resp, model = self.cluster_template_client.get_cluster_template( + new_model.uuid) + self.assertEqual(200, resp.status) + self.assertTrue(model.public) + + @testtools.testcase.attr('positive') + def test_update_cluster_template_by_uuid(self): + gen_model = \ + datagen.cluster_template_data_with_valid_keypair_image_flavor() + resp, old_model = self._create_cluster_template(gen_model) + + patch_model = datagen.cluster_template_name_patch_data() + resp, new_model = self.cluster_template_client.patch_cluster_template( + old_model.uuid, patch_model) + self.assertEqual(200, resp.status) + + resp, model = \ + self.cluster_template_client.get_cluster_template(new_model.uuid) + self.assertEqual(200, resp.status) + self.assertEqual(old_model.uuid, new_model.uuid) + self.assertEqual(model.name, new_model.name) + + @testtools.testcase.attr('positive') + def test_delete_cluster_template_by_uuid(self): + gen_model = \ + datagen.cluster_template_data_with_valid_keypair_image_flavor() + resp, model = self._create_cluster_template(gen_model) + resp, _ = self.cluster_template_client.delete_cluster_template( + model.uuid) + self.assertEqual(204, resp.status) + self.cluster_templates.remove(model.uuid) + + @testtools.testcase.attr('positive') + def test_delete_cluster_template_by_name(self): + gen_model = \ + datagen.cluster_template_data_with_valid_keypair_image_flavor() + resp, model = self._create_cluster_template(gen_model) + resp, _ = self.cluster_template_client.delete_cluster_template( + model.name) + self.assertEqual(204, resp.status) + self.cluster_templates.remove(model.uuid) + + @testtools.testcase.attr('negative') + def test_get_cluster_template_by_uuid_404(self): + self.assertRaises( + exceptions.NotFound, + self.cluster_template_client.get_cluster_template, + data_utils.rand_uuid()) + + @testtools.testcase.attr('negative') + def test_update_cluster_template_404(self): + patch_model = datagen.cluster_template_name_patch_data() + + self.assertRaises( + exceptions.NotFound, + self.cluster_template_client.patch_cluster_template, + data_utils.rand_uuid(), patch_model) + + @testtools.testcase.attr('negative') + def test_delete_cluster_template_404(self): + self.assertRaises( + exceptions.NotFound, + self.cluster_template_client.delete_cluster_template, + data_utils.rand_uuid()) + + @testtools.testcase.attr('negative') + def test_get_cluster_template_by_name_404(self): + self.assertRaises( + exceptions.NotFound, + self.cluster_template_client.get_cluster_template, 'fooo') + + @testtools.testcase.attr('negative') + def test_update_cluster_template_name_not_found(self): + patch_model = datagen.cluster_template_name_patch_data() + + self.assertRaises( + exceptions.NotFound, + self.cluster_template_client.patch_cluster_template, + 'fooo', patch_model) + + @testtools.testcase.attr('negative') + def test_delete_cluster_template_by_name_404(self): + self.assertRaises( + exceptions.NotFound, + self.cluster_template_client.get_cluster_template, 'fooo') + + @testtools.testcase.attr('negative') + def test_create_cluster_template_missing_image(self): + gen_model = datagen.cluster_template_data_with_missing_image() + self.assertRaises( + exceptions.BadRequest, + self.cluster_template_client.post_cluster_template, gen_model) + + @testtools.testcase.attr('negative') + def test_create_cluster_template_missing_flavor(self): + gen_model = datagen.cluster_template_data_with_missing_flavor() + self.assertRaises( + exceptions.BadRequest, + self.cluster_template_client.post_cluster_template, gen_model) + + @testtools.testcase.attr('negative') + def test_create_cluster_template_missing_keypair(self): + gen_model = \ + datagen.cluster_template_data_with_missing_keypair() + self.assertRaises( + exceptions.NotFound, + self.cluster_template_client.post_cluster_template, gen_model) + + @testtools.testcase.attr('negative') + def test_update_cluster_template_invalid_patch(self): + # get json object + gen_model = \ + datagen.cluster_template_data_with_valid_keypair_image_flavor() + resp, old_model = self._create_cluster_template(gen_model) + + self.assertRaises( + exceptions.BadRequest, + self.cluster_template_client.patch_cluster_template, + data_utils.rand_uuid(), gen_model) + + @testtools.testcase.attr('negative') + def test_create_cluster_template_invalid_network_driver(self): + gen_model = \ + datagen.cluster_template_data_with_valid_keypair_image_flavor() + gen_model.network_driver = 'invalid_network_driver' + self.assertRaises( + exceptions.BadRequest, + self.cluster_template_client.post_cluster_template, gen_model) + + @testtools.testcase.attr('negative') + def test_create_cluster_template_invalid_volume_driver(self): + gen_model = \ + datagen.cluster_template_data_with_valid_keypair_image_flavor() + gen_model.volume_driver = 'invalid_volume_driver' + self.assertRaises( + exceptions.BadRequest, + self.cluster_template_client.post_cluster_template, gen_model) diff --git a/magnum/tests/functional/common/base.py b/magnum/tests/functional/common/base.py index cd4a3c6a48..ed496e6c0e 100644 --- a/magnum/tests/functional/common/base.py +++ b/magnum/tests/functional/common/base.py @@ -79,9 +79,9 @@ class BaseMagnumTest(base.BaseTestCase): ]) except Exception: cls.LOG.error(msg) - msg = (_LE("failed to copy from %{node_address}s " - "to %{base_path}s%{log_name}s-" - "%{node_address}s") % + msg = (_LE("failed to copy from %(node_address)s " + "to %(base_path)s%(log_name)s-" + "%(node_address)s") % {'node_address': node_address, 'base_path': "/opt/stack/logs/bay-nodes/", 'log_name': log_name}) diff --git a/magnum/tests/functional/common/datagen.py b/magnum/tests/functional/common/datagen.py index 417ea71dca..14a03c8a1e 100644 --- a/magnum/tests/functional/common/datagen.py +++ b/magnum/tests/functional/common/datagen.py @@ -23,6 +23,10 @@ from magnum.tests.functional.api.v1.models import baymodel_model from magnum.tests.functional.api.v1.models import baymodelpatch_model from magnum.tests.functional.api.v1.models import baypatch_model from magnum.tests.functional.api.v1.models import cert_model +from magnum.tests.functional.api.v1.models import cluster_model +from magnum.tests.functional.api.v1.models import cluster_template_model +from magnum.tests.functional.api.v1.models import cluster_templatepatch_model +from magnum.tests.functional.api.v1.models import clusterpatch_model from magnum.tests.functional.common import config @@ -334,3 +338,277 @@ def cert_data(bay_uuid, csr_data=None): model = cert_model.CertEntity.from_dict(data) return model + + +def cluster_template_data(**kwargs): + """Generates random cluster_template data + + Keypair and image id cannot be random for the cluster_template to be valid + due to validations for the presence of keypair and image id prior to + cluster_template creation. + + :param keypair_id: keypair name + :param image_id: image id or name + :returns: ClusterTemplateEntity with generated data + """ + + data = { + "name": data_utils.rand_name('cluster'), + "coe": "swarm", + "tls_disabled": False, + "network_driver": None, + "volume_driver": None, + "docker_volume_size": 3, + "labels": {}, + "public": False, + "fixed_network": "192.168.0.0/24", + "dns_nameserver": "8.8.8.8", + "flavor_id": data_utils.rand_name('cluster'), + "master_flavor_id": data_utils.rand_name('cluster'), + "external_network_id": config.Config.nic_id, + "keypair_id": data_utils.rand_name('cluster'), + "image_id": data_utils.rand_name('cluster') + } + + data.update(kwargs) + model = cluster_template_model.ClusterTemplateEntity.from_dict(data) + + return model + + +def cluster_template_replace_patch_data(path, + value=data_utils.rand_name('cluster')): + """Generates random ClusterTemplate patch data + + :param path: path to replace + :param value: value to replace in patch + :returns: ClusterTemplatePatchCollection with generated data + """ + + data = [{ + "path": path, + "value": value, + "op": "replace" + }] + collection = cluster_templatepatch_model.ClusterTemplatePatchCollection + return collection.from_dict(data) + + +def cluster_template_remove_patch_data(path): + """Generates ClusterTempalte patch data by removing value + + :param path: path to remove + :returns: BayModelPatchCollection with generated data + """ + + data = [{ + "path": path, + "op": "remove" + }] + collection = cluster_templatepatch_model.ClusterTemplatePatchCollection + return collection.from_dict(data) + + +def cluster_template_name_patch_data(name=data_utils.rand_name('cluster')): + """Generates random cluster_template patch data + + :param name: name to replace in patch + :returns: ClusterTemplatePatchCollection with generated data + """ + + data = [{ + "path": "/name", + "value": name, + "op": "replace" + }] + collection = cluster_templatepatch_model.ClusterTemplatePatchCollection + return collection.from_dict(data) + + +def cluster_template_flavor_patch_data(flavor=data_utils.rand_name('cluster')): + """Generates random cluster_template patch data + + :param flavor: flavor to replace in patch + :returns: ClusterTemplatePatchCollection with generated data + """ + + data = [{ + "path": "/flavor_id", + "value": flavor, + "op": "replace" + }] + collection = cluster_templatepatch_model.ClusterTemplatePatchCollection + return collection.from_dict(data) + + +def cluster_template_data_with_valid_keypair_image_flavor(): + """Generates random clustertemplate data with valid data + + :returns: ClusterTemplateEntity with generated data + """ + master_flavor = config.Config.master_flavor_id + return cluster_template_data(keypair_id=config.Config.keypair_id, + image_id=config.Config.image_id, + flavor_id=config.Config.flavor_id, + master_flavor_id=master_flavor) + + +def cluster_template_data_with_missing_image(): + """Generates random cluster_template data with missing image + + :returns: ClusterTemplateEntity with generated data + """ + + return cluster_template_data( + keypair_id=config.Config.keypair_id, + flavor_id=config.Config.flavor_id, + master_flavor_id=config.Config.master_flavor_id) + + +def cluster_template_data_with_missing_flavor(): + """Generates random cluster_template data with missing flavor + + :returns: ClusterTemplateEntity with generated data + """ + + return cluster_template_data(keypair_id=config.Config.keypair_id, + image_id=config.Config.image_id) + + +def cluster_template_data_with_missing_keypair(): + """Generates random cluster_template data with missing keypair + + :returns: ClusterTemplateEntity with generated data + """ + + return cluster_template_data( + image_id=config.Config.image_id, + flavor_id=config.Config.flavor_id, + master_flavor_id=config.Config.master_flavor_id) + + +def cluster_template_valid_data_with_specific_coe(coe): + """Generates random cluster_template data with valid keypair and image + + :param coe: coe + :returns: ClusterTemplateEntity with generated data + """ + + return cluster_template_data(keypair_id=config.Config.keypair_id, + image_id=config.Config.image_id, coe=coe) + + +def valid_swarm_cluster_template(is_public=False): + """Generates a valid swarm cluster_template with valid data + + :returns: ClusterTemplateEntity with generated data + """ + master_flavor_id = config.Config.master_flavor_id + return cluster_template_data(image_id=config.Config.image_id, + fixed_network="192.168.0.0/24", + flavor_id=config.Config.flavor_id, + public=is_public, + dns_nameserver=config.Config.dns_nameserver, + master_flavor_id=master_flavor_id, + keypair_id=config.Config.keypair_id, + coe="swarm", docker_volume_size=3, + cluster_distro=None, + external_network_id=config.Config.nic_id, + http_proxy=None, https_proxy=None, + no_proxy=None, network_driver=None, + volume_driver=None, labels={}, + tls_disabled=False) + + +def cluster_data(name=data_utils.rand_name('cluster'), + cluster_template_id=data_utils.rand_uuid(), + node_count=random_int(1, 5), discovery_url=gen_random_ip(), + create_timeout=random_int(1, 30), + master_count=random_int(1, 5)): + """Generates random cluster data + + cluster_template_id cannot be random for the cluster to be valid due to + validations for the presence of clustertemplate prior to clustertemplate + creation. + + :param name: cluster name (must be unique) + :param cluster_template_id: clustertemplate unique id (must already exist) + :param node_count: number of agents for cluster + :param discovery_url: url provided for node discovery + :param create_timeout: timeout in minutes for cluster create + :param master_count: number of master nodes for the cluster + :returns: ClusterEntity with generated data + """ + + data = { + "name": name, + "cluster_template_id": cluster_template_id, + "node_count": node_count, + "discovery_url": None, + "create_timeout": create_timeout, + "master_count": master_count + } + model = cluster_model.ClusterEntity.from_dict(data) + + return model + + +def valid_cluster_data(cluster_template_id, + name=data_utils.rand_name('cluster'), + node_count=1, master_count=1, create_timeout=None): + """Generates random cluster data with valid + + :param cluster_template_id: clustertemplate unique id that already exists + :param name: cluster name (must be unique) + :param node_count: number of agents for cluster + :returns: ClusterEntity with generated data + """ + + return cluster_data(cluster_template_id=cluster_template_id, name=name, + master_count=master_count, node_count=node_count, + create_timeout=create_timeout) + + +def cluster_name_patch_data(name=data_utils.rand_name('cluster')): + """Generates random clustertemplate patch data + + :param name: name to replace in patch + :returns: ClusterPatchCollection with generated data + """ + + data = [{ + "path": "/name", + "value": name, + "op": "replace" + }] + return clusterpatch_model.ClusterPatchCollection.from_dict(data) + + +def cluster_api_addy_patch_data(address='0.0.0.0'): + """Generates random cluster patch data + + :param name: name to replace in patch + :returns: ClusterPatchCollection with generated data + """ + + data = [{ + "path": "/api_address", + "value": address, + "op": "replace" + }] + return clusterpatch_model.ClusterPatchCollection.from_dict(data) + + +def cluster_node_count_patch_data(node_count=2): + """Generates random cluster patch data + + :param name: name to replace in patch + :returns: ClusterPatchCollection with generated data + """ + + data = [{ + "path": "/node_count", + "value": node_count, + "op": "replace" + }] + return clusterpatch_model.ClusterPatchCollection.from_dict(data) diff --git a/magnum/tests/functional/common/manager.py b/magnum/tests/functional/common/manager.py index 27cb28ac1e..fd04467e28 100644 --- a/magnum/tests/functional/common/manager.py +++ b/magnum/tests/functional/common/manager.py @@ -16,6 +16,8 @@ from tempest.common import credentials_factory as common_creds from magnum.tests.functional.api.v1.clients import bay_client from magnum.tests.functional.api.v1.clients import baymodel_client from magnum.tests.functional.api.v1.clients import cert_client +from magnum.tests.functional.api.v1.clients import cluster_client +from magnum.tests.functional.api.v1.clients import cluster_template_client from magnum.tests.functional.api.v1.clients import magnum_service_client from magnum.tests.functional.common import client from magnum.tests.functional.common import config @@ -29,17 +31,21 @@ class Manager(clients.Manager): super(Manager, self).__init__(credentials, 'container-infra') self.auth_provider.orig_base_url = self.auth_provider.base_url self.auth_provider.base_url = self.bypassed_base_url + auth = self.auth_provider if request_type == 'baymodel': - self.client = baymodel_client.BayModelClient(self.auth_provider) + self.client = baymodel_client.BayModelClient(auth) elif request_type == 'bay': - self.client = bay_client.BayClient(self.auth_provider) + self.client = bay_client.BayClient(auth) elif request_type == 'cert': - self.client = cert_client.CertClient(self.auth_provider) + self.client = cert_client.CertClient(auth) + elif request_type == 'cluster_template': + self.client = cluster_template_client.ClusterTemplateClient(auth) + elif request_type == 'cluster': + self.client = cluster_client.ClusterClient(auth) elif request_type == 'service': - self.client = magnum_service_client.MagnumServiceClient( - self.auth_provider) + self.client = magnum_service_client.MagnumServiceClient(auth) else: - self.client = client.MagnumClient(self.auth_provider) + self.client = client.MagnumClient(auth) def bypassed_base_url(self, filters, auth_data=None): if (config.Config.magnum_url and diff --git a/magnum/tests/unit/api/controllers/test_root.py b/magnum/tests/unit/api/controllers/test_root.py index 90118625da..dfe1fcfa25 100644 --- a/magnum/tests/unit/api/controllers/test_root.py +++ b/magnum/tests/unit/api/controllers/test_root.py @@ -33,20 +33,20 @@ class TestRootController(api_base.FunctionalTest): super(TestRootController, self).setUp() self.root_expected = { u'description': u'Magnum is an OpenStack project which ' - 'aims to provide container management.', + 'aims to provide container cluster management.', u'name': u'OpenStack Magnum API', u'versions': [{u'id': u'v1', - u'links': - [{u'href': u'http://localhost/v1/', - u'rel': u'self'}], + u'links': + [{u'href': u'http://localhost/v1/', + u'rel': u'self'}], u'status': u'CURRENT', u'max_version': u'1.3', u'min_version': u'1.1'}]} self.v1_expected = { u'media_types': - [{u'base': u'application/json', - u'type': u'application/vnd.openstack.magnum.v1+json'}], + [{u'base': u'application/json', + u'type': u'application/vnd.openstack.magnum.v1+json'}], u'links': [{u'href': u'http://localhost/v1/', u'rel': u'self'}, {u'href': @@ -61,6 +61,15 @@ class TestRootController(api_base.FunctionalTest): u'rel': u'self'}, {u'href': u'http://localhost/baymodels/', u'rel': u'bookmark'}], + u'clusters': [{u'href': u'http://localhost/v1/clusters/', + u'rel': u'self'}, + {u'href': u'http://localhost/clusters/', + u'rel': u'bookmark'}], + u'clustertemplates': + [{u'href': u'http://localhost/v1/clustertemplates/', + u'rel': u'self'}, + {u'href': u'http://localhost/clustertemplates/', + u'rel': u'bookmark'}], u'id': u'v1', u'certificates': [{u'href': u'http://localhost/v1/certificates/', u'rel': u'self'}, @@ -199,7 +208,6 @@ class TestHeathcheck(api_base.FunctionalTest): class TestV1Routing(api_base.FunctionalTest): - def test_route_checks_version(self): self.get_json('/') self._check_version.assert_called_once_with(mock.ANY, @@ -207,7 +215,6 @@ class TestV1Routing(api_base.FunctionalTest): class TestCheckVersions(test_base.TestCase): - def setUp(self): super(TestCheckVersions, self).setUp() diff --git a/magnum/tests/unit/api/controllers/v1/test_cluster.py b/magnum/tests/unit/api/controllers/v1/test_cluster.py new file mode 100644 index 0000000000..8cdb0fee6d --- /dev/null +++ b/magnum/tests/unit/api/controllers/v1/test_cluster.py @@ -0,0 +1,895 @@ +# 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 datetime + +import mock +from oslo_config import cfg +from oslo_utils import timeutils +from oslo_utils import uuidutils + +from magnum.api import attr_validator +from magnum.api.controllers.v1 import cluster as api_cluster +from magnum.common import exception +from magnum.conductor import api as rpcapi +from magnum import objects +from magnum.tests import base +from magnum.tests.unit.api import base as api_base +from magnum.tests.unit.api import utils as apiutils +from magnum.tests.unit.objects import utils as obj_utils + + +class TestClusterObject(base.TestCase): + def test_cluster_init(self): + cluster_dict = apiutils.cluster_post_data(cluster_template_id=None) + del cluster_dict['node_count'] + del cluster_dict['master_count'] + del cluster_dict['create_timeout'] + cluster = api_cluster.Cluster(**cluster_dict) + self.assertEqual(1, cluster.node_count) + self.assertEqual(1, cluster.master_count) + self.assertEqual(60, cluster.create_timeout) + + +class TestListCluster(api_base.FunctionalTest): + _cluster_attrs = ("name", "cluster_template_id", "node_count", "status", + "master_count", "stack_id", "create_timeout") + + _expand_cluster_attrs = ("name", "cluster_template_id", "node_count", + "status", "api_address", "discovery_url", + "node_addresses", "master_count", + "master_addresses", "stack_id", + "create_timeout", "status_reason") + + def setUp(self): + super(TestListCluster, self).setUp() + obj_utils.create_test_cluster_template(self.context) + + def test_empty(self): + response = self.get_json('/clusters') + self.assertEqual([], response['clusters']) + + def test_one(self): + cluster = obj_utils.create_test_cluster(self.context) + response = self.get_json('/clusters') + self.assertEqual(cluster.uuid, response['clusters'][0]["uuid"]) + self._verify_attrs(self._cluster_attrs, response['clusters'][0]) + + # Verify attrs do not appear from cluster's get_all response + none_attrs = \ + set(self._expand_cluster_attrs) - set(self._cluster_attrs) + self._verify_attrs(none_attrs, response['clusters'][0], + positive=False) + + def test_get_one(self): + cluster = obj_utils.create_test_cluster(self.context) + response = self.get_json('/clusters/%s' % cluster['uuid']) + self.assertEqual(cluster.uuid, response['uuid']) + self._verify_attrs(self._expand_cluster_attrs, response) + + @mock.patch('magnum.common.clients.OpenStackClients.heat') + def test_get_one_failed_cluster(self, mock_heat): + fake_resources = mock.MagicMock() + fake_resources.resource_name = 'fake_name' + fake_resources.resource_status_reason = 'fake_reason' + + ht = mock.MagicMock() + ht.resources.list.return_value = [fake_resources] + mock_heat.return_value = ht + + cluster = obj_utils.create_test_cluster(self.context, + status='CREATE_FAILED') + response = self.get_json('/clusters/%s' % cluster['uuid']) + self.assertEqual(cluster.uuid, response['uuid']) + self.assertEqual({'fake_name': 'fake_reason'}, response['faults']) + + @mock.patch('magnum.common.clients.OpenStackClients.heat') + def test_get_one_failed_cluster_heatclient_exception(self, mock_heat): + mock_heat.resources.list.side_effect = Exception('fake') + cluster = obj_utils.create_test_cluster(self.context, + status='CREATE_FAILED') + response = self.get_json('/clusters/%s' % cluster['uuid']) + self.assertEqual(cluster.uuid, response['uuid']) + self.assertEqual({}, response['faults']) + + def test_get_one_by_name(self): + cluster = obj_utils.create_test_cluster(self.context) + response = self.get_json('/clusters/%s' % cluster['name']) + self.assertEqual(cluster.uuid, response['uuid']) + self._verify_attrs(self._expand_cluster_attrs, response) + + def test_get_one_by_name_not_found(self): + response = self.get_json( + '/clusters/not_found', + expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_get_one_by_name_multiple_cluster(self): + obj_utils.create_test_cluster(self.context, name='test_cluster', + uuid=uuidutils.generate_uuid()) + obj_utils.create_test_cluster(self.context, name='test_cluster', + uuid=uuidutils.generate_uuid()) + response = self.get_json('/clusters/test_cluster', + expect_errors=True) + self.assertEqual(409, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_get_all_with_pagination_marker(self): + cluster_list = [] + for id_ in range(4): + temp_uuid = uuidutils.generate_uuid() + cluster = obj_utils.create_test_cluster(self.context, id=id_, + uuid=temp_uuid) + cluster_list.append(cluster) + + response = self.get_json('/clusters?limit=3&marker=%s' + % cluster_list[2].uuid) + self.assertEqual(1, len(response['clusters'])) + self.assertEqual(cluster_list[-1].uuid, + response['clusters'][0]['uuid']) + + def test_detail(self): + cluster = obj_utils.create_test_cluster(self.context) + response = self.get_json('/clusters/detail') + self.assertEqual(cluster.uuid, response['clusters'][0]["uuid"]) + self._verify_attrs(self._expand_cluster_attrs, + response['clusters'][0]) + + def test_detail_with_pagination_marker(self): + cluster_list = [] + for id_ in range(4): + temp_uuid = uuidutils.generate_uuid() + cluster = obj_utils.create_test_cluster(self.context, id=id_, + uuid=temp_uuid) + cluster_list.append(cluster) + + response = self.get_json('/clusters/detail?limit=3&marker=%s' + % cluster_list[2].uuid) + self.assertEqual(1, len(response['clusters'])) + self.assertEqual(cluster_list[-1].uuid, + response['clusters'][0]['uuid']) + self._verify_attrs(self._expand_cluster_attrs, + response['clusters'][0]) + + def test_detail_against_single(self): + cluster = obj_utils.create_test_cluster(self.context) + response = self.get_json('/clusters/%s/detail' % cluster['uuid'], + expect_errors=True) + self.assertEqual(404, response.status_int) + + def test_many(self): + bm_list = [] + for id_ in range(5): + temp_uuid = uuidutils.generate_uuid() + cluster = obj_utils.create_test_cluster(self.context, id=id_, + uuid=temp_uuid) + bm_list.append(cluster.uuid) + response = self.get_json('/clusters') + self.assertEqual(len(bm_list), len(response['clusters'])) + uuids = [b['uuid'] for b in response['clusters']] + self.assertEqual(sorted(bm_list), sorted(uuids)) + + def test_links(self): + uuid = uuidutils.generate_uuid() + obj_utils.create_test_cluster(self.context, id=1, uuid=uuid) + response = self.get_json('/clusters/%s' % uuid) + self.assertIn('links', response.keys()) + self.assertEqual(2, len(response['links'])) + self.assertIn(uuid, response['links'][0]['href']) + for l in response['links']: + bookmark = l['rel'] == 'bookmark' + self.assertTrue(self.validate_link(l['href'], + bookmark=bookmark)) + + def test_collection_links(self): + for id_ in range(5): + obj_utils.create_test_cluster(self.context, id=id_, + uuid=uuidutils.generate_uuid()) + response = self.get_json('/clusters/?limit=3') + self.assertEqual(3, len(response['clusters'])) + + next_marker = response['clusters'][-1]['uuid'] + self.assertIn(next_marker, response['next']) + + def test_collection_links_default_limit(self): + cfg.CONF.set_override('max_limit', 3, 'api') + for id_ in range(5): + obj_utils.create_test_cluster(self.context, id=id_, + uuid=uuidutils.generate_uuid()) + response = self.get_json('/clusters') + self.assertEqual(3, len(response['clusters'])) + + next_marker = response['clusters'][-1]['uuid'] + self.assertIn(next_marker, response['next']) + + +class TestPatch(api_base.FunctionalTest): + def setUp(self): + super(TestPatch, self).setUp() + self.cluster_template_obj = obj_utils.create_test_cluster_template( + self.context) + self.cluster_obj = obj_utils.create_test_cluster( + self.context, name='cluster_example_A', node_count=3) + p = mock.patch.object(rpcapi.API, 'bay_update_async') + self.mock_bay_update = p.start() + self.mock_bay_update.side_effect = self._simulate_rpc_bay_update + self.addCleanup(p.stop) + + def _simulate_rpc_bay_update(self, bay): + bay.save() + return bay + + @mock.patch('oslo_utils.timeutils.utcnow') + def test_replace_ok(self, mock_utcnow): + new_node_count = 4 + test_time = datetime.datetime(2000, 1, 1, 0, 0) + mock_utcnow.return_value = test_time + + response = self.patch_json('/clusters/%s' % self.cluster_obj.uuid, + [{'path': '/node_count', + 'value': new_node_count, + 'op': 'replace'}]) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_code) + + response = self.get_json('/clusters/%s' % self.cluster_obj.uuid) + self.assertEqual(new_node_count, response['node_count']) + return_updated_at = timeutils.parse_isotime( + response['updated_at']).replace(tzinfo=None) + self.assertEqual(test_time, return_updated_at) + # Assert nothing else was changed + self.assertEqual(self.cluster_obj.uuid, response['uuid']) + self.assertEqual(self.cluster_obj.baymodel_id, + response['cluster_template_id']) + + @mock.patch('oslo_utils.timeutils.utcnow') + def test_replace_ok_by_name(self, mock_utcnow): + new_node_count = 4 + test_time = datetime.datetime(2000, 1, 1, 0, 0) + mock_utcnow.return_value = test_time + + response = self.patch_json('/clusters/%s' % self.cluster_obj.name, + [{'path': '/node_count', + 'value': new_node_count, + 'op': 'replace'}]) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_code) + + response = self.get_json('/clusters/%s' % self.cluster_obj.uuid) + self.assertEqual(new_node_count, response['node_count']) + return_updated_at = timeutils.parse_isotime( + response['updated_at']).replace(tzinfo=None) + self.assertEqual(test_time, return_updated_at) + # Assert nothing else was changed + self.assertEqual(self.cluster_obj.uuid, response['uuid']) + self.assertEqual(self.cluster_obj.baymodel_id, + response['cluster_template_id']) + + @mock.patch('oslo_utils.timeutils.utcnow') + def test_replace_ok_by_name_not_found(self, mock_utcnow): + name = 'not_found' + test_time = datetime.datetime(2000, 1, 1, 0, 0) + mock_utcnow.return_value = test_time + + response = self.patch_json('/clusters/%s' % name, + [{'path': '/name', 'value': name, + 'op': 'replace'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(404, response.status_code) + + def test_replace_cluster_template_id_failed(self): + cluster_template = obj_utils.create_test_cluster_template( + self.context, + uuid=uuidutils.generate_uuid()) + response = self.patch_json('/clusters/%s' % self.cluster_obj.uuid, + [{'path': '/cluster_template_id', + 'value': cluster_template.uuid, + 'op': 'replace'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_code) + self.assertTrue(response.json['errors']) + + @mock.patch('oslo_utils.timeutils.utcnow') + def test_replace_ok_by_name_multiple_cluster(self, mock_utcnow): + test_time = datetime.datetime(2000, 1, 1, 0, 0) + mock_utcnow.return_value = test_time + + obj_utils.create_test_cluster(self.context, name='test_cluster', + uuid=uuidutils.generate_uuid()) + obj_utils.create_test_cluster(self.context, name='test_cluster', + uuid=uuidutils.generate_uuid()) + + response = self.patch_json('/clusters/test_cluster', + [{'path': '/name', + 'value': 'test_cluster', + 'op': 'replace'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(409, response.status_code) + + def test_replace_non_existent_cluster_template_id(self): + response = self.patch_json('/clusters/%s' % self.cluster_obj.uuid, + [{'path': '/cluster_template_id', + 'value': uuidutils.generate_uuid(), + 'op': 'replace'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_code) + self.assertTrue(response.json['errors']) + + def test_replace_invalid_node_count(self): + response = self.patch_json('/clusters/%s' % self.cluster_obj.uuid, + [{'path': '/node_count', 'value': -1, + 'op': 'replace'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_code) + self.assertTrue(response.json['errors']) + + def test_replace_non_existent_cluster(self): + response = self.patch_json('/clusters/%s' % + uuidutils.generate_uuid(), + [{'path': '/name', + 'value': 'cluster_example_B', + 'op': 'replace'}], + expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_replace_cluster_name_failed(self): + response = self.patch_json('/clusters/%s' % self.cluster_obj.uuid, + [{'path': '/name', + 'value': 'cluster_example_B', + 'op': 'replace'}], + expect_errors=True) + self.assertEqual(400, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_add_non_existent_property(self): + response = self.patch_json( + '/clusters/%s' % self.cluster_obj.uuid, + [{'path': '/foo', 'value': 'bar', 'op': 'add'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_remove_ok(self): + response = self.get_json('/clusters/%s' % self.cluster_obj.uuid) + self.assertIsNotNone(response['name']) + + response = self.patch_json('/clusters/%s' % self.cluster_obj.uuid, + [{'path': '/node_count', + 'op': 'remove'}]) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_code) + + response = self.get_json('/clusters/%s' % self.cluster_obj.uuid) + # only allow node_count for cluster, and default value is 1 + self.assertEqual(1, response['node_count']) + # Assert nothing else was changed + self.assertEqual(self.cluster_obj.uuid, response['uuid']) + self.assertEqual(self.cluster_obj.baymodel_id, + response['cluster_template_id']) + self.assertEqual(self.cluster_obj.name, response['name']) + self.assertEqual(self.cluster_obj.master_count, + response['master_count']) + + def test_remove_uuid(self): + response = self.patch_json('/clusters/%s' % self.cluster_obj.uuid, + [{'path': '/uuid', 'op': 'remove'}], + expect_errors=True) + self.assertEqual(400, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_remove_cluster_template_id(self): + response = self.patch_json('/clusters/%s' % self.cluster_obj.uuid, + [{'path': '/cluster_template_id', + 'op': 'remove'}], + expect_errors=True) + self.assertEqual(400, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_remove_non_existent_property(self): + response = self.patch_json( + '/clusters/%s' % self.cluster_obj.uuid, + [{'path': '/non-existent', 'op': 'remove'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_code) + self.assertTrue(response.json['errors']) + + +class TestPost(api_base.FunctionalTest): + def setUp(self): + super(TestPost, self).setUp() + self.cluster_template = obj_utils.create_test_cluster_template( + self.context) + p = mock.patch.object(rpcapi.API, 'bay_create_async') + self.mock_bay_create = p.start() + self.mock_bay_create.side_effect = self._simulate_rpc_bay_create + self.addCleanup(p.stop) + p = mock.patch.object(attr_validator, 'validate_os_resources') + self.mock_valid_os_res = p.start() + self.addCleanup(p.stop) + + def _simulate_rpc_bay_create(self, bay, bay_create_timeout): + bay.create() + return bay + + @mock.patch('oslo_utils.timeutils.utcnow') + def test_create_cluster(self, mock_utcnow): + bdict = apiutils.cluster_post_data() + test_time = datetime.datetime(2000, 1, 1, 0, 0) + mock_utcnow.return_value = test_time + + response = self.post_json('/clusters', bdict) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + self.assertTrue(uuidutils.is_uuid_like(response.json['uuid'])) + + def test_create_cluster_set_project_id_and_user_id(self): + bdict = apiutils.cluster_post_data() + + def _simulate_rpc_bay_create(bay, bay_create_timeout): + self.assertEqual(self.context.project_id, bay.project_id) + self.assertEqual(self.context.user_id, bay.user_id) + bay.create() + return bay + + self.mock_bay_create.side_effect = _simulate_rpc_bay_create + + self.post_json('/clusters', bdict) + + def test_create_cluster_doesnt_contain_id(self): + with mock.patch.object(self.dbapi, 'create_bay', + wraps=self.dbapi.create_bay) as cc_mock: + bdict = apiutils.cluster_post_data(name='cluster_example_A') + response = self.post_json('/clusters', bdict) + cc_mock.assert_called_once_with(mock.ANY) + # Check that 'id' is not in first arg of positional args + self.assertNotIn('id', cc_mock.call_args[0][0]) + self.assertTrue(uuidutils.is_uuid_like(response.json['uuid'])) + + def test_create_cluster_generate_uuid(self): + bdict = apiutils.cluster_post_data() + del bdict['uuid'] + + response = self.post_json('/clusters', bdict) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + self.assertTrue(uuidutils.is_uuid_like(response.json['uuid'])) + + def test_create_cluster_no_cluster_template_id(self): + bdict = apiutils.cluster_post_data() + del bdict['cluster_template_id'] + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + + def test_create_cluster_with_non_existent_cluster_template_id(self): + temp_uuid = uuidutils.generate_uuid() + bdict = apiutils.cluster_post_data(cluster_template_id=temp_uuid) + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_create_cluster_with_cluster_template_name(self): + modelname = self.cluster_template.name + bdict = apiutils.cluster_post_data(cluster_template_id=modelname) + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_node_count_zero(self): + bdict = apiutils.cluster_post_data() + bdict['node_count'] = 0 + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_create_cluster_with_node_count_negative(self): + bdict = apiutils.cluster_post_data() + bdict['node_count'] = -1 + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_create_cluster_with_no_node_count(self): + bdict = apiutils.cluster_post_data() + del bdict['node_count'] + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_master_count_zero(self): + bdict = apiutils.cluster_post_data() + bdict['master_count'] = 0 + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_create_cluster_with_no_master_count(self): + bdict = apiutils.cluster_post_data() + del bdict['master_count'] + response = self.post_json('/clusters', bdict) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_invalid_long_name(self): + bdict = apiutils.cluster_post_data(name='x' * 243) + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_create_cluster_with_invalid_integer_name(self): + bdict = apiutils.cluster_post_data(name='123456') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_create_cluster_with_invalid_integer_str_name(self): + bdict = apiutils.cluster_post_data(name='123456test_cluster') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_create_cluster_with_hyphen_invalid_at_start_name(self): + bdict = apiutils.cluster_post_data(name='-test_cluster') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_create_cluster_with_period_invalid_at_start_name(self): + bdict = apiutils.cluster_post_data(name='.test_cluster') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_create_cluster_with_underscore_invalid_at_start_name(self): + bdict = apiutils.cluster_post_data(name='_test_cluster') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_create_cluster_with_valid_str_int_name(self): + bdict = apiutils.cluster_post_data(name='test_cluster123456') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_hyphen_valid_name(self): + bdict = apiutils.cluster_post_data(name='test-cluster') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_period_valid_name(self): + bdict = apiutils.cluster_post_data(name='test.cluster') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_period_at_end_valid_name(self): + bdict = apiutils.cluster_post_data(name='testcluster.') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_hyphen_at_end_valid_name(self): + bdict = apiutils.cluster_post_data(name='testcluster-') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_underscore_at_end_valid_name(self): + bdict = apiutils.cluster_post_data(name='testcluster_') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_mix_special_char_valid_name(self): + bdict = apiutils.cluster_post_data(name='test.-_cluster') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_capital_letter_start_valid_name(self): + bdict = apiutils.cluster_post_data(name='Testcluster') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_invalid_empty_name(self): + bdict = apiutils.cluster_post_data(name='') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_create_cluster_without_name(self): + bdict = apiutils.cluster_post_data() + del bdict['name'] + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_timeout_none(self): + bdict = apiutils.cluster_post_data() + bdict['create_timeout'] = None + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_no_timeout(self): + def _simulate_rpc_bay_create(bay, bay_create_timeout): + self.assertEqual(60, bay_create_timeout) + bay.create() + return bay + + self.mock_bay_create.side_effect = _simulate_rpc_bay_create + bdict = apiutils.cluster_post_data() + del bdict['create_timeout'] + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_timeout_negative(self): + bdict = apiutils.cluster_post_data() + bdict['create_timeout'] = -1 + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_create_cluster_with_timeout_zero(self): + bdict = apiutils.cluster_post_data() + bdict['create_timeout'] = 0 + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_invalid_flavor(self): + bdict = apiutils.cluster_post_data() + self.mock_valid_os_res.side_effect = exception.FlavorNotFound( + 'test-flavor') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertTrue(self.mock_valid_os_res.called) + self.assertEqual(400, response.status_int) + + def test_create_cluster_with_invalid_ext_network(self): + bdict = apiutils.cluster_post_data() + self.mock_valid_os_res.side_effect = \ + exception.ExternalNetworkNotFound('test-net') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertTrue(self.mock_valid_os_res.called) + self.assertEqual(400, response.status_int) + + def test_create_cluster_with_invalid_keypair(self): + bdict = apiutils.cluster_post_data() + self.mock_valid_os_res.side_effect = exception.KeyPairNotFound( + 'test-key') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertTrue(self.mock_valid_os_res.called) + self.assertEqual(404, response.status_int) + + def test_create_cluster_with_nonexist_image(self): + bdict = apiutils.cluster_post_data() + self.mock_valid_os_res.side_effect = exception.ImageNotFound( + 'test-img') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertTrue(self.mock_valid_os_res.called) + self.assertEqual(400, response.status_int) + + def test_create_cluster_with_multi_images_same_name(self): + bdict = apiutils.cluster_post_data() + self.mock_valid_os_res.side_effect = exception.Conflict('test-img') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertTrue(self.mock_valid_os_res.called) + self.assertEqual(409, response.status_int) + + def test_create_cluster_with_on_os_distro_image(self): + bdict = apiutils.cluster_post_data() + self.mock_valid_os_res.side_effect = \ + exception.OSDistroFieldNotFound('img') + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertTrue(self.mock_valid_os_res.called) + self.assertEqual(400, response.status_int) + + def test_create_cluster_with_no_lb_one_node(self): + cluster_template = obj_utils.create_test_cluster_template( + self.context, name='foo', uuid='foo', master_lb_enabled=False) + bdict = apiutils.cluster_post_data( + cluster_template_id=cluster_template.name, master_count=1) + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_int) + + def test_create_cluster_with_no_lb_multi_node(self): + cluster_template = obj_utils.create_test_cluster_template( + self.context, name='foo', uuid='foo', master_lb_enabled=False) + bdict = apiutils.cluster_post_data( + cluster_template_id=cluster_template.name, master_count=3) + response = self.post_json('/clusters', bdict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + + +class TestDelete(api_base.FunctionalTest): + def setUp(self): + super(TestDelete, self).setUp() + self.cluster_template = obj_utils.create_test_cluster_template( + self.context) + self.cluster = obj_utils.create_test_cluster(self.context) + p = mock.patch.object(rpcapi.API, 'bay_delete_async') + self.mock_bay_delete = p.start() + self.mock_bay_delete.side_effect = self._simulate_rpc_bay_delete + self.addCleanup(p.stop) + + def _simulate_rpc_bay_delete(self, bay_uuid): + bay = objects.Bay.get_by_uuid(self.context, bay_uuid) + bay.destroy() + + def test_delete_cluster(self): + self.delete('/clusters/%s' % self.cluster.uuid) + response = self.get_json('/clusters/%s' % self.cluster.uuid, + expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_delete_cluster_not_found(self): + uuid = uuidutils.generate_uuid() + response = self.delete('/clusters/%s' % uuid, expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_delete_cluster_with_name_not_found(self): + response = self.delete('/clusters/not_found', expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_delete_cluster_with_name(self): + response = self.delete('/clusters/%s' % self.cluster.name, + expect_errors=True) + self.assertEqual(204, response.status_int) + + def test_delete_multiple_cluster_by_name(self): + obj_utils.create_test_cluster(self.context, name='test_cluster', + uuid=uuidutils.generate_uuid()) + obj_utils.create_test_cluster(self.context, name='test_cluster', + uuid=uuidutils.generate_uuid()) + response = self.delete('/clusters/test_cluster', expect_errors=True) + self.assertEqual(409, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + +class TestClusterPolicyEnforcement(api_base.FunctionalTest): + def setUp(self): + super(TestClusterPolicyEnforcement, self).setUp() + obj_utils.create_test_cluster_template(self.context) + + def _common_policy_check(self, rule, func, *arg, **kwarg): + self.policy.set_rules({rule: "project:non_fake"}) + response = func(*arg, **kwarg) + self.assertEqual(403, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue( + "Policy doesn't allow %s to be performed." % rule, + response.json['errors'][0]['detail']) + + def test_policy_disallow_get_all(self): + self._common_policy_check( + "cluster:get_all", self.get_json, '/clusters', expect_errors=True) + + def test_policy_disallow_get_one(self): + self.cluster = obj_utils.create_test_cluster(self.context) + self._common_policy_check( + "cluster:get", self.get_json, '/clusters/%s' % self.cluster.uuid, + expect_errors=True) + + def test_policy_disallow_detail(self): + self._common_policy_check( + "cluster:detail", self.get_json, + '/clusters/%s/detail' % uuidutils.generate_uuid(), + expect_errors=True) + + def test_policy_disallow_update(self): + self.cluster = obj_utils.create_test_cluster(self.context, + name='cluster_example_A', + node_count=3) + self._common_policy_check( + "cluster:update", self.patch_json, '/clusters/%s' % + self.cluster.name, + [{'path': '/name', 'value': "new_name", 'op': 'replace'}], + expect_errors=True) + + def test_policy_disallow_create(self): + bdict = apiutils.cluster_post_data(name='cluster_example_A') + self._common_policy_check( + "cluster:create", self.post_json, '/clusters', bdict, + expect_errors=True) + + def _simulate_rpc_bay_delete(self, bay_uuid): + bay = objects.Bay.get_by_uuid(self.context, bay_uuid) + bay.destroy() + + def test_policy_disallow_delete(self): + p = mock.patch.object(rpcapi.API, 'bay_delete') + self.mock_bay_delete = p.start() + self.mock_bay_delete.side_effect = self._simulate_rpc_bay_delete + self.addCleanup(p.stop) + self.cluster = obj_utils.create_test_cluster(self.context) + self._common_policy_check( + "cluster:delete", self.delete, '/clusters/%s' % + self.cluster.uuid, + expect_errors=True) + + def _owner_check(self, rule, func, *args, **kwargs): + self.policy.set_rules({rule: "user_id:%(user_id)s"}) + response = func(*args, **kwargs) + self.assertEqual(403, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue( + "Policy doesn't allow %s to be performed." % rule, + response.json['errors'][0]['detail']) + + def test_policy_only_owner_get_one(self): + cluster = obj_utils.create_test_cluster(self.context, + user_id='another') + self._owner_check("cluster:get", self.get_json, + '/clusters/%s' % cluster.uuid, + expect_errors=True) + + def test_policy_only_owner_update(self): + cluster = obj_utils.create_test_cluster(self.context, + user_id='another') + self._owner_check( + "cluster:update", self.patch_json, + '/clusters/%s' % cluster.uuid, + [{'path': '/name', 'value': "new_name", 'op': 'replace'}], + expect_errors=True) + + def test_policy_only_owner_delete(self): + cluster = obj_utils.create_test_cluster(self.context, + user_id='another') + self._owner_check("cluster:delete", self.delete, + '/clusters/%s' % cluster.uuid, + expect_errors=True) diff --git a/magnum/tests/unit/api/controllers/v1/test_cluster_template.py b/magnum/tests/unit/api/controllers/v1/test_cluster_template.py new file mode 100644 index 0000000000..85ffb822df --- /dev/null +++ b/magnum/tests/unit/api/controllers/v1/test_cluster_template.py @@ -0,0 +1,1088 @@ +# 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 datetime + +import mock +from oslo_config import cfg +from oslo_utils import timeutils +from oslo_utils import uuidutils +from six.moves.urllib import parse as urlparse +from webtest.app import AppError +from wsme import types as wtypes + +from magnum.api import attr_validator +from magnum.api.controllers.v1 import cluster_template as api_cluster_template +from magnum.common import exception +from magnum.common import policy as magnum_policy +from magnum.tests import base +from magnum.tests.unit.api import base as api_base +from magnum.tests.unit.api import utils as apiutils +from magnum.tests.unit.objects import utils as obj_utils + + +class TestClusterTemplateObject(base.TestCase): + + def test_cluster_template_init(self): + cluster_template_dict = apiutils.cluster_template_post_data() + del cluster_template_dict['image_id'] + cluster_template = api_cluster_template.ClusterTemplate( + **cluster_template_dict) + self.assertEqual(wtypes.Unset, cluster_template.image_id) + + +class TestListClusterTemplate(api_base.FunctionalTest): + + _cluster_template_attrs = ('name', 'apiserver_port', 'network_driver', + 'coe', 'flavor_id', 'fixed_network', + 'dns_nameserver', 'http_proxy', + 'docker_volume_size', 'server_type', + 'cluster_distro', 'external_network_id', + 'image_id', 'registry_enabled', 'no_proxy', + 'keypair_id', 'https_proxy', 'tls_disabled', + 'public', 'labels', 'master_flavor_id', + 'volume_driver', 'insecure_registry') + + def test_empty(self): + response = self.get_json('/clustertemplates') + self.assertEqual([], response['clustertemplates']) + + def test_one(self): + cluster_template = obj_utils.create_test_cluster_template(self.context) + response = self.get_json('/clustertemplates') + self.assertEqual(cluster_template.uuid, + response['clustertemplates'][0]["uuid"]) + self._verify_attrs(self._cluster_template_attrs, + response['clustertemplates'][0]) + + def test_get_one(self): + cluster_template = obj_utils.create_test_cluster_template(self.context) + response = self.get_json('/clustertemplates/%s' % + cluster_template['uuid']) + self.assertEqual(cluster_template.uuid, response['uuid']) + self._verify_attrs(self._cluster_template_attrs, response) + + def test_get_one_by_name(self): + cluster_template = obj_utils.create_test_cluster_template(self.context) + response = self.get_json('/clustertemplates/%s' % + cluster_template['name']) + self.assertEqual(cluster_template.uuid, response['uuid']) + self._verify_attrs(self._cluster_template_attrs, response) + + def test_get_one_by_name_not_found(self): + response = self.get_json( + '/clustertemplates/not_found', + expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_get_one_by_name_multiple_cluster_template(self): + obj_utils.create_test_cluster_template( + self.context, name='test_clustertemplate', + uuid=uuidutils.generate_uuid()) + obj_utils.create_test_cluster_template( + self.context, name='test_clustertemplate', + uuid=uuidutils.generate_uuid()) + response = self.get_json( + '/clustertemplates/test_clustertemplate', + expect_errors=True) + self.assertEqual(409, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_get_all_with_pagination_marker(self): + bm_list = [] + for id_ in range(4): + cluster_template = obj_utils.create_test_cluster_template( + self.context, id=id_, + uuid=uuidutils.generate_uuid()) + bm_list.append(cluster_template) + + response = self.get_json('/clustertemplates?limit=3&marker=%s' + % bm_list[2].uuid) + self.assertEqual(1, len(response['clustertemplates'])) + self.assertEqual(bm_list[-1].uuid, + response['clustertemplates'][0]['uuid']) + + def test_detail(self): + cluster_template = obj_utils.create_test_cluster_template(self.context) + response = self.get_json('/clustertemplates/detail') + self.assertEqual(cluster_template.uuid, + response['clustertemplates'][0]["uuid"]) + self._verify_attrs(self._cluster_template_attrs, + response['clustertemplates'][0]) + + def test_detail_with_pagination_marker(self): + bm_list = [] + for id_ in range(4): + cluster_template = obj_utils.create_test_cluster_template( + self.context, id=id_, + uuid=uuidutils.generate_uuid()) + bm_list.append(cluster_template) + + response = self.get_json('/clustertemplates/detail?limit=3&marker=%s' + % bm_list[2].uuid) + self.assertEqual(1, len(response['clustertemplates'])) + self.assertEqual(bm_list[-1].uuid, + response['clustertemplates'][0]['uuid']) + self._verify_attrs(self._cluster_template_attrs, + response['clustertemplates'][0]) + + def test_detail_against_single(self): + cluster_template = obj_utils.create_test_cluster_template(self.context) + response = self.get_json('/clustertemplates/%s/detail' % + cluster_template['uuid'], + expect_errors=True) + self.assertEqual(404, response.status_int) + + def test_many(self): + bm_list = [] + for id_ in range(5): + cluster_template = obj_utils.create_test_cluster_template( + self.context, id=id_, + uuid=uuidutils.generate_uuid()) + bm_list.append(cluster_template.uuid) + response = self.get_json('/clustertemplates') + self.assertEqual(len(bm_list), len(response['clustertemplates'])) + uuids = [bm['uuid'] for bm in response['clustertemplates']] + self.assertEqual(sorted(bm_list), sorted(uuids)) + + def test_links(self): + uuid = uuidutils.generate_uuid() + obj_utils.create_test_cluster_template(self.context, id=1, uuid=uuid) + response = self.get_json('/clustertemplates/%s' % uuid) + self.assertIn('links', response.keys()) + self.assertEqual(2, len(response['links'])) + self.assertIn(uuid, response['links'][0]['href']) + for l in response['links']: + bookmark = l['rel'] == 'bookmark' + self.assertTrue(self.validate_link(l['href'], + bookmark=bookmark)) + + def test_collection_links(self): + for id_ in range(5): + obj_utils.create_test_cluster_template( + self.context, id=id_, uuid=uuidutils.generate_uuid()) + response = self.get_json('/clustertemplates/?limit=3') + self.assertEqual(3, len(response['clustertemplates'])) + + next_marker = response['clustertemplates'][-1]['uuid'] + self.assertIn(next_marker, response['next']) + + def test_collection_links_default_limit(self): + cfg.CONF.set_override('max_limit', 3, 'api') + for id_ in range(5): + obj_utils.create_test_cluster_template( + self.context, id=id_, uuid=uuidutils.generate_uuid()) + response = self.get_json('/clustertemplates') + self.assertEqual(3, len(response['clustertemplates'])) + + next_marker = response['clustertemplates'][-1]['uuid'] + self.assertIn(next_marker, response['next']) + + +class TestPatch(api_base.FunctionalTest): + + def setUp(self): + super(TestPatch, self).setUp() + p = mock.patch.object(attr_validator, 'validate_os_resources') + self.mock_valid_os_res = p.start() + self.addCleanup(p.stop) + self.cluster_template = obj_utils.create_test_cluster_template( + self.context, + name='cluster_model_example_A', + image_id='nerdherd', + apiserver_port=8080, + fixed_network='private', + flavor_id='m1.magnum', + master_flavor_id='m1.magnum', + external_network_id='public', + keypair_id='test', + volume_driver='rexray', + public=False, + docker_volume_size=20, + coe='swarm', + labels={'key1': 'val1', 'key2': 'val2'} + ) + + def test_update_not_found(self): + uuid = uuidutils.generate_uuid() + response = self.patch_json('/clustertemplates/%s' % uuid, + [{'path': '/name', + 'value': 'cluster_model_example_B', + 'op': 'add'}], + expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_update_cluster_template_with_cluster(self): + cluster_template = obj_utils.create_test_cluster_template(self.context) + obj_utils.create_test_cluster(self.context, + baymodel_id=cluster_template.uuid) + + response = self.patch_json('/clustertemplates/%s' % + cluster_template.uuid, + [{'path': '/name', + 'value': 'cluster_model_example_B', + 'op': 'replace'}], + expect_errors=True) + self.assertEqual(400, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + self.assertIn(cluster_template.uuid, + response.json['errors'][0]['detail']) + + @mock.patch.object(magnum_policy, 'enforce') + def test_update_public_cluster_template_success(self, mock_policy): + mock_policy.return_value = True + response = self.patch_json('/clustertemplates/%s' % + self.cluster_template.uuid, + [{'path': '/public', 'value': True, + 'op': 'replace'}]) + self.assertEqual('application/json', response.content_type) + self.assertEqual(200, response.status_code) + + response = self.get_json('/clustertemplates/%s' % + self.cluster_template.uuid) + self.assertTrue(response['public']) + + @mock.patch.object(magnum_policy, 'enforce') + def test_update_public_cluster_template_fail(self, mock_policy): + mock_policy.return_value = False + self.assertRaises(AppError, self.patch_json, + '/clustertemplates/%s' % self.cluster_template.uuid, + [{'path': '/public', 'value': True, + 'op': 'replace'}]) + + def test_update_cluster_template_with_cluster_allow_update(self): + cluster_template = obj_utils.create_test_cluster_template(self.context) + obj_utils.create_test_cluster(self.context, + baymodel_id=cluster_template.uuid) + response = self.patch_json('/clustertemplates/%s' % + cluster_template.uuid, + [{'path': '/public', + 'value': True, + 'op': 'replace'}], + expect_errors=True) + self.assertEqual(200, response.status_int) + response = self.get_json('/clustertemplates/%s' % + self.cluster_template.uuid) + self.assertEqual(response['public'], True) + + def test_update_cluster_template_with_cluster_not_allow_update(self): + cluster_template = obj_utils.create_test_cluster_template(self.context) + obj_utils.create_test_cluster(self.context, + baymodel_id=cluster_template.uuid) + response = self.patch_json('/clustertemplates/%s' % + cluster_template.uuid, + [{'path': '/name', + 'value': 'new_name', + 'op': 'replace'}], + expect_errors=True) + self.assertEqual(400, response.status_code) + + @mock.patch('oslo_utils.timeutils.utcnow') + def test_replace_singular(self, mock_utcnow): + name = 'cluster_model_example_B' + test_time = datetime.datetime(2000, 1, 1, 0, 0) + + mock_utcnow.return_value = test_time + response = self.patch_json('/clustertemplates/%s' % + self.cluster_template.uuid, + [{'path': '/name', 'value': name, + 'op': 'replace'}]) + self.assertEqual('application/json', response.content_type) + self.assertEqual(200, response.status_code) + + response = self.get_json('/clustertemplates/%s' % + self.cluster_template.uuid) + self.assertEqual(name, response['name']) + return_updated_at = timeutils.parse_isotime( + response['updated_at']).replace(tzinfo=None) + self.assertEqual(test_time, return_updated_at) + # Assert nothing else was changed + self.assertEqual(self.cluster_template.uuid, response['uuid']) + self.assertEqual(self.cluster_template.image_id, response['image_id']) + self.assertEqual(self.cluster_template.apiserver_port, + response['apiserver_port']) + self.assertEqual(self.cluster_template.fixed_network, + response['fixed_network']) + self.assertEqual(self.cluster_template.network_driver, + response['network_driver']) + self.assertEqual(self.cluster_template.volume_driver, + response['volume_driver']) + self.assertEqual(self.cluster_template.docker_volume_size, + response['docker_volume_size']) + self.assertEqual(self.cluster_template.coe, + response['coe']) + self.assertEqual(self.cluster_template.http_proxy, + response['http_proxy']) + self.assertEqual(self.cluster_template.https_proxy, + response['https_proxy']) + self.assertEqual(self.cluster_template.no_proxy, + response['no_proxy']) + self.assertEqual(self.cluster_template.labels, + response['labels']) + + def test_replace_cluster_template_with_no_exist_flavor_id(self): + self.mock_valid_os_res.side_effect = exception.FlavorNotFound("aaa") + response = self.patch_json('/clustertemplates/%s' % + self.cluster_template.uuid, + [{'path': '/flavor_id', 'value': 'aaa', + 'op': 'replace'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_code) + self.assertTrue(response.json['errors']) + + def test_replace_cluster_template_with_no_exist_keypair_id(self): + self.mock_valid_os_res.side_effect = exception.KeyPairNotFound("aaa") + response = self.patch_json('/clustertemplates/%s' % + self.cluster_template.uuid, + [{'path': '/keypair_id', 'value': 'aaa', + 'op': 'replace'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(404, response.status_code) + self.assertTrue(response.json['errors']) + + def test_replace_cluster_template_with_no_exist_external_network_id(self): + self.mock_valid_os_res.side_effect = exception.ExternalNetworkNotFound( + "aaa") + response = self.patch_json('/clustertemplates/%s' % + self.cluster_template.uuid, + [{'path': '/external_network_id', + 'value': 'aaa', + 'op': 'replace'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_code) + self.assertTrue(response.json['errors']) + + def test_replace_cluster_template_with_no_exist_image_id(self): + self.mock_valid_os_res.side_effect = exception.ImageNotFound("aaa") + response = self.patch_json('/clustertemplates/%s' % + self.cluster_template.uuid, + [{'path': '/image_id', 'value': 'aaa', + 'op': 'replace'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_code) + self.assertTrue(response.json['errors']) + + def test_create_cluster_template_with_no_os_distro_image(self): + image_exce = exception.OSDistroFieldNotFound('img') + self.mock_valid_os_res.side_effect = image_exce + response = self.patch_json('/clustertemplates/%s' % + self.cluster_template.uuid, + [{'path': '/image_id', 'value': 'img', + 'op': 'replace'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_code) + self.assertTrue(response.json['errors']) + + def test_remove_singular(self): + cluster_template = obj_utils.create_test_cluster_template( + self.context, + uuid=uuidutils.generate_uuid()) + response = self.get_json('/clustertemplates/%s' % + cluster_template.uuid) + self.assertIsNotNone(response['dns_nameserver']) + + response = self.patch_json('/clustertemplates/%s' % + cluster_template.uuid, + [{'path': '/dns_nameserver', + 'op': 'remove'}]) + self.assertEqual('application/json', response.content_type) + self.assertEqual(200, response.status_code) + + response = self.get_json('/clustertemplates/%s' % + cluster_template.uuid) + self.assertIsNone(response['dns_nameserver']) + # Assert nothing else was changed + self.assertEqual(cluster_template.uuid, response['uuid']) + self.assertEqual(cluster_template.name, response['name']) + self.assertEqual(cluster_template.apiserver_port, + response['apiserver_port']) + self.assertEqual(cluster_template.image_id, + response['image_id']) + self.assertEqual(cluster_template.fixed_network, + response['fixed_network']) + self.assertEqual(cluster_template.network_driver, + response['network_driver']) + self.assertEqual(cluster_template.volume_driver, + response['volume_driver']) + self.assertEqual(cluster_template.docker_volume_size, + response['docker_volume_size']) + self.assertEqual(cluster_template.coe, response['coe']) + self.assertEqual(cluster_template.http_proxy, response['http_proxy']) + self.assertEqual(cluster_template.https_proxy, response['https_proxy']) + self.assertEqual(cluster_template.no_proxy, response['no_proxy']) + self.assertEqual(cluster_template.labels, response['labels']) + + def test_remove_non_existent_property_fail(self): + response = self.patch_json('/clustertemplates/%s' % + self.cluster_template.uuid, + [{'path': '/non-existent', + 'op': 'remove'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_code) + self.assertTrue(response.json['errors']) + + def test_remove_mandatory_property_fail(self): + mandatory_properties = ('/image_id', '/keypair_id', + '/external_network_id', '/coe', + '/tls_disabled', '/public', + '/registry_enabled', '/server_type', + '/cluster_distro', '/network_driver') + for p in mandatory_properties: + response = self.patch_json('/clustertemplates/%s' % + self.cluster_template.uuid, + [{'path': p, 'op': 'remove'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_code) + self.assertTrue(response.json['errors']) + + def test_add_root_non_existent(self): + response = self.patch_json( + '/clustertemplates/%s' % self.cluster_template.uuid, + [{'path': '/foo', 'value': 'bar', 'op': 'add'}], + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(400, response.status_int) + self.assertTrue(response.json['errors']) + + def test_remove_uuid(self): + response = self.patch_json('/clustertemplates/%s' % + self.cluster_template.uuid, + [{'path': '/uuid', 'op': 'remove'}], + expect_errors=True) + self.assertEqual(400, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + +class TestPost(api_base.FunctionalTest): + + def setUp(self): + super(TestPost, self).setUp() + p = mock.patch.object(attr_validator, 'validate_os_resources') + self.mock_valid_os_res = p.start() + self.addCleanup(p.stop) + + @mock.patch('magnum.api.attr_validator.validate_image') + @mock.patch('oslo_utils.timeutils.utcnow') + def test_create_cluster_template(self, mock_utcnow, + mock_image_data): + bdict = apiutils.cluster_template_post_data() + test_time = datetime.datetime(2000, 1, 1, 0, 0) + mock_utcnow.return_value = test_time + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + + response = self.post_json('/clustertemplates', bdict) + self.assertEqual(201, response.status_int) + # Check location header + self.assertIsNotNone(response.location) + expected_location = '/v1/clustertemplates/%s' % bdict['uuid'] + self.assertEqual(expected_location, + urlparse.urlparse(response.location).path) + self.assertEqual(bdict['uuid'], response.json['uuid']) + self.assertNotIn('updated_at', response.json.keys) + return_created_at = timeutils.parse_isotime( + response.json['created_at']).replace(tzinfo=None) + self.assertEqual(test_time, return_created_at) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_set_project_id_and_user_id( + self, mock_image_data): + with mock.patch.object(self.dbapi, 'create_baymodel', + wraps=self.dbapi.create_baymodel) as cc_mock: + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data() + self.post_json('/clustertemplates', bdict) + cc_mock.assert_called_once_with(mock.ANY) + self.assertEqual(self.context.project_id, + cc_mock.call_args[0][0]['project_id']) + self.assertEqual(self.context.user_id, + cc_mock.call_args[0][0]['user_id']) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_doesnt_contain_id(self, + mock_image_data): + with mock.patch.object(self.dbapi, 'create_baymodel', + wraps=self.dbapi.create_baymodel) as cc_mock: + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data(image_id='my-image') + response = self.post_json('/clustertemplates', bdict) + self.assertEqual(bdict['image_id'], response.json['image_id']) + cc_mock.assert_called_once_with(mock.ANY) + # Check that 'id' is not in first arg of positional args + self.assertNotIn('id', cc_mock.call_args[0][0]) + + def _create_model_raises_app_error(self, **kwargs): + # Create mock for db and image data + with mock.patch.object(self.dbapi, 'create_baymodel', + wraps=self.dbapi.create_baymodel) as cc_mock,\ + mock.patch('magnum.api.attr_validator.validate_image')\ + as mock_image_data: + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data(**kwargs) + self.assertRaises(AppError, self.post_json, '/clustertemplates', + bdict) + self.assertFalse(cc_mock.called) + + def test_create_cluster_template_with_invalid_long_string(self): + fields = ["uuid", "name", "image_id", "flavor_id", "master_flavor_id", + "dns_nameserver", "keypair_id", "external_network_id", + "cluster_distro", "fixed_network", "apiserver_port", + "docker_volume_size", "http_proxy", "https_proxy", + "no_proxy", "network_driver", "labels", "volume_driver"] + for field in fields: + self._create_model_raises_app_error(**{field: 'i' * 256}) + + def test_create_cluster_template_with_invalid_empty_string(self): + fields = ["uuid", "name", "image_id", "flavor_id", "master_flavor_id", + "dns_nameserver", "keypair_id", "external_network_id", + "cluster_distro", "fixed_network", "apiserver_port", + "docker_volume_size", "labels", "http_proxy", "https_proxy", + "no_proxy", "network_driver", "volume_driver", "coe"] + for field in fields: + self._create_model_raises_app_error(**{field: ''}) + + def test_create_cluster_template_with_invalid_coe(self): + self._create_model_raises_app_error(coe='k8s') + self._create_model_raises_app_error(coe='storm') + self._create_model_raises_app_error(coe='meson') + self._create_model_raises_app_error(coe='osomatsu') + + def test_create_cluster_template_with_invalid_docker_volume_size(self): + self._create_model_raises_app_error(docker_volume_size=0) + self._create_model_raises_app_error(docker_volume_size=-1) + self._create_model_raises_app_error( + docker_volume_size=1, + docker_storage_driver="devicemapper") + self._create_model_raises_app_error( + docker_volume_size=2, + docker_storage_driver="devicemapper") + self._create_model_raises_app_error(docker_volume_size='notanint') + + def test_create_cluster_template_with_invalid_dns_nameserver(self): + self._create_model_raises_app_error(dns_nameserver='1.1.2') + self._create_model_raises_app_error(dns_nameserver='1.1..1') + self._create_model_raises_app_error(dns_nameserver='openstack.org') + + def test_create_cluster_template_with_invalid_apiserver_port(self): + self._create_model_raises_app_error(apiserver_port=-12) + self._create_model_raises_app_error(apiserver_port=65536) + self._create_model_raises_app_error(apiserver_port=0) + self._create_model_raises_app_error(apiserver_port=1023) + self._create_model_raises_app_error(apiserver_port='not an int') + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_labels(self, mock_image_data): + with mock.patch.object(self.dbapi, 'create_baymodel', + wraps=self.dbapi.create_baymodel) as cc_mock: + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data( + labels={'key1': 'val1', 'key2': 'val2'}) + response = self.post_json('/clustertemplates', bdict) + self.assertEqual(bdict['labels'], + response.json['labels']) + cc_mock.assert_called_once_with(mock.ANY) + self.assertNotIn('id', cc_mock.call_args[0][0]) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_docker_volume_size(self, + mock_image_data): + with mock.patch.object(self.dbapi, 'create_baymodel', + wraps=self.dbapi.create_baymodel) as cc_mock: + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data(docker_volume_size=99) + response = self.post_json('/clustertemplates', bdict) + self.assertEqual(bdict['docker_volume_size'], + response.json['docker_volume_size']) + cc_mock.assert_called_once_with(mock.ANY) + self.assertNotIn('id', cc_mock.call_args[0][0]) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_overlay(self, mock_image_data): + with mock.patch.object(self.dbapi, 'create_baymodel', + wraps=self.dbapi.create_baymodel) as cc_mock: + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data( + docker_volume_size=1, docker_storage_driver="overlay") + response = self.post_json('/clustertemplates', bdict) + self.assertEqual(bdict['docker_volume_size'], + response.json['docker_volume_size']) + cc_mock.assert_called_once_with(mock.ANY) + self.assertNotIn('id', cc_mock.call_args[0][0]) + + @mock.patch('magnum.api.attr_validator.validate_image') + def _test_create_cluster_template_network_driver_attr( + self, + cluster_template_dict, + cluster_template_config_dict, + expect_errors, + mock_image_data): + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + for k, v in cluster_template_config_dict.items(): + cfg.CONF.set_override(k, v, 'baymodel') + with mock.patch.object(self.dbapi, 'create_baymodel', + wraps=self.dbapi.create_baymodel) as cc_mock: + bdict = apiutils.cluster_template_post_data( + **cluster_template_dict) + response = self.post_json('/clustertemplates', bdict, + expect_errors=expect_errors) + if expect_errors: + self.assertEqual(400, response.status_int) + else: + expected_driver = bdict.get('network_driver') + if not expected_driver: + expected_driver = ( + cfg.CONF.baymodel.swarm_default_network_driver) + self.assertEqual(expected_driver, + response.json['network_driver']) + self.assertEqual(bdict['image_id'], + response.json['image_id']) + cc_mock.assert_called_once_with(mock.ANY) + self.assertNotIn('id', cc_mock.call_args[0][0]) + self.assertTrue(uuidutils.is_uuid_like(response.json['uuid'])) + + def test_create_cluster_template_with_network_driver(self): + cluster_template_dict = {'coe': 'kubernetes', + 'network_driver': 'flannel'} + config_dict = {} # Default config + expect_errors_flag = False + self._test_create_cluster_template_network_driver_attr( + cluster_template_dict, + config_dict, + expect_errors_flag) + + def test_create_cluster_template_with_no_network_driver(self): + cluster_template_dict = {} + config_dict = {} + expect_errors_flag = False + self._test_create_cluster_template_network_driver_attr( + cluster_template_dict, + config_dict, + expect_errors_flag) + + def test_create_cluster_template_with_network_driver_non_def_config(self): + cluster_template_dict = {'coe': 'kubernetes', + 'network_driver': 'flannel'} + config_dict = { + 'kubernetes_allowed_network_drivers': ['flannel', 'foo']} + expect_errors_flag = False + self._test_create_cluster_template_network_driver_attr( + cluster_template_dict, + config_dict, + expect_errors_flag) + + def test_create_cluster_template_with_invalid_network_driver(self): + cluster_template_dict = {'coe': 'kubernetes', + 'network_driver': 'bad_driver'} + config_dict = { + 'kubernetes_allowed_network_drivers': ['flannel', 'good_driver']} + expect_errors_flag = True + self._test_create_cluster_template_network_driver_attr( + cluster_template_dict, + config_dict, + expect_errors_flag) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_volume_driver(self, + mock_image_data): + with mock.patch.object(self.dbapi, 'create_baymodel', + wraps=self.dbapi.create_baymodel) as cc_mock: + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data(volume_driver='rexray') + response = self.post_json('/clustertemplates', bdict) + self.assertEqual(bdict['volume_driver'], + response.json['volume_driver']) + cc_mock.assert_called_once_with(mock.ANY) + self.assertNotIn('id', cc_mock.call_args[0][0]) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_no_volume_driver(self, + mock_image_data): + with mock.patch.object(self.dbapi, 'create_baymodel', + wraps=self.dbapi.create_baymodel) as cc_mock: + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data() + response = self.post_json('/clustertemplates', bdict) + self.assertEqual(bdict['volume_driver'], + response.json['volume_driver']) + cc_mock.assert_called_once_with(mock.ANY) + self.assertNotIn('id', cc_mock.call_args[0][0]) + + @mock.patch('magnum.api.attr_validator.validate_image') + @mock.patch.object(magnum_policy, 'enforce') + def test_create_cluster_template_public_success(self, mock_policy, + mock_image_data): + with mock.patch.object(self.dbapi, 'create_baymodel', + wraps=self.dbapi.create_baymodel) as cc_mock: + mock_policy.return_value = True + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data(public=True) + response = self.post_json('/clustertemplates', bdict) + self.assertTrue(response.json['public']) + mock_policy.assert_called_with(mock.ANY, + "clustertemplate:publish", + None, do_raise=False) + cc_mock.assert_called_once_with(mock.ANY) + self.assertNotIn('id', cc_mock.call_args[0][0]) + self.assertTrue(cc_mock.call_args[0][0]['public']) + + @mock.patch('magnum.api.attr_validator.validate_image') + @mock.patch.object(magnum_policy, 'enforce') + def test_create_cluster_template_public_fail(self, mock_policy, + mock_image_data): + with mock.patch.object(self.dbapi, 'create_baymodel', + wraps=self.dbapi.create_baymodel): + # make policy enforcement fail + mock_policy.return_value = False + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data(public=True) + self.assertRaises(AppError, self.post_json, '/clustertemplates', + bdict) + + @mock.patch('magnum.api.attr_validator.validate_image') + @mock.patch.object(magnum_policy, 'enforce') + def test_create_cluster_template_public_not_set(self, mock_policy, + mock_image_data): + with mock.patch.object(self.dbapi, 'create_baymodel', + wraps=self.dbapi.create_baymodel) as cc_mock: + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data(public=False) + response = self.post_json('/clustertemplates', bdict) + self.assertFalse(response.json['public']) + # policy enforcement is called only once for enforce_wsgi + self.assertEqual(1, mock_policy.call_count) + cc_mock.assert_called_once_with(mock.ANY) + self.assertNotIn('id', cc_mock.call_args[0][0]) + self.assertFalse(cc_mock.call_args[0][0]['public']) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_no_os_distro_image(self, + mock_image_data): + mock_image_data.side_effect = exception.OSDistroFieldNotFound('img') + bdict = apiutils.cluster_template_post_data() + del bdict['uuid'] + response = self.post_json('/clustertemplates', bdict, + expect_errors=True) + self.assertEqual(400, response.status_int) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_os_distro_image(self, + mock_image_data): + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data() + del bdict['uuid'] + response = self.post_json('/clustertemplates', bdict, + expect_errors=True) + self.assertEqual(201, response.status_int) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_image_name(self, + mock_image_data): + mock_image = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + mock_image_data.return_value = mock_image + bdict = apiutils.cluster_template_post_data() + del bdict['uuid'] + response = self.post_json('/clustertemplates', bdict, + expect_errors=True) + self.assertEqual(201, response.status_int) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_no_exist_image_name(self, + mock_image_data): + mock_image_data.side_effect = exception.ResourceNotFound('test-img') + bdict = apiutils.cluster_template_post_data() + del bdict['uuid'] + response = self.post_json('/clustertemplates', bdict, + expect_errors=True) + self.assertEqual(404, response.status_int) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_multi_image_name(self, + mock_image_data): + mock_image_data.side_effect = exception.Conflict('Multiple images') + bdict = apiutils.cluster_template_post_data() + del bdict['uuid'] + response = self.post_json('/clustertemplates', bdict, + expect_errors=True) + self.assertEqual(409, response.status_int) + + def test_create_cluster_template_without_image_id(self): + bdict = apiutils.cluster_template_post_data() + del bdict['image_id'] + response = self.post_json('/clustertemplates', bdict, + expect_errors=True) + self.assertEqual(400, response.status_int) + + def test_create_cluster_template_without_keypair_id(self): + bdict = apiutils.cluster_template_post_data() + del bdict['keypair_id'] + response = self.post_json('/clustertemplates', bdict, + expect_errors=True) + self.assertEqual(400, response.status_int) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_dns(self, + mock_image_data): + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data() + response = self.post_json('/clustertemplates', bdict) + self.assertEqual(201, response.status_int) + self.assertEqual(bdict['dns_nameserver'], + response.json['dns_nameserver']) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_no_exist_keypair(self, + mock_image_data): + self.mock_valid_os_res.side_effect = exception.KeyPairNotFound("Test") + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data() + response = self.post_json('/clustertemplates', bdict, + expect_errors=True) + self.assertEqual(404, response.status_int) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_flavor(self, + mock_image_data): + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data() + response = self.post_json('/clustertemplates', bdict) + self.assertEqual(201, response.status_int) + self.assertEqual(bdict['flavor_id'], + response.json['flavor_id']) + self.assertEqual(bdict['master_flavor_id'], + response.json['master_flavor_id']) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_no_exist_flavor(self, + mock_image_data): + self.mock_valid_os_res.side_effect = exception.FlavorNotFound("flavor") + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data() + response = self.post_json('/clustertemplates', bdict, + expect_errors=True) + self.assertEqual(400, response.status_int) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_with_external_network(self, + mock_image_data): + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data() + response = self.post_json('/clustertemplates', bdict) + self.assertEqual(201, response.status_int) + self.assertEqual(bdict['external_network_id'], + response.json['external_network_id']) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_no_exist_external_network( + self, mock_image_data): + self.mock_valid_os_res.side_effect = exception.ExternalNetworkNotFound( + "test") + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data() + response = self.post_json('/clustertemplates', bdict, + expect_errors=True) + self.assertEqual(400, response.status_int) + + @mock.patch('magnum.api.attr_validator.validate_image') + def test_create_cluster_template_without_name(self, mock_image_data): + with mock.patch.object(self.dbapi, 'create_baymodel', + wraps=self.dbapi.create_baymodel): + mock_image_data.return_value = {'name': 'mock_name', + 'os_distro': 'fedora-atomic'} + bdict = apiutils.cluster_template_post_data() + bdict.pop('name') + resp = self.post_json('/clustertemplates', bdict) + self.assertEqual(201, resp.status_int) + self.assertIsNotNone(resp.json['name']) + + +class TestDelete(api_base.FunctionalTest): + + def test_delete_cluster_template(self): + cluster_template = obj_utils.create_test_cluster_template(self.context) + self.delete('/clustertemplates/%s' % cluster_template.uuid) + response = self.get_json('/clustertemplates/%s' % + cluster_template.uuid, + expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_delete_cluster_template_with_cluster(self): + cluster_template = obj_utils.create_test_cluster_template(self.context) + obj_utils.create_test_cluster(self.context, + baymodel_id=cluster_template.uuid) + response = self.delete('/clustertemplates/%s' % cluster_template.uuid, + expect_errors=True) + self.assertEqual(400, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + self.assertIn(cluster_template.uuid, + response.json['errors'][0]['detail']) + + def test_delete_cluster_template_not_found(self): + uuid = uuidutils.generate_uuid() + response = self.delete('/clustertemplates/%s' % uuid, + expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_delete_cluster_template_with_name(self): + cluster_template = obj_utils.create_test_cluster_template(self.context) + response = self.delete('/clustertemplates/%s' % + cluster_template['name'], + expect_errors=True) + self.assertEqual(204, response.status_int) + + def test_delete_cluster_template_with_name_not_found(self): + response = self.delete('/clustertemplates/not_found', + expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_delete_multiple_cluster_template_by_name(self): + obj_utils.create_test_cluster_template(self.context, + name='test_cluster_template', + uuid=uuidutils.generate_uuid()) + obj_utils.create_test_cluster_template(self.context, + name='test_cluster_template', + uuid=uuidutils.generate_uuid()) + response = self.delete('/clustertemplates/test_cluster_template', + expect_errors=True) + self.assertEqual(409, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + +class TestClusterTemplatePolicyEnforcement(api_base.FunctionalTest): + + def _common_policy_check(self, rule, func, *arg, **kwarg): + self.policy.set_rules({rule: "project:non_fake"}) + response = func(*arg, **kwarg) + self.assertEqual(403, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue( + "Policy doesn't allow %s to be performed." % rule, + response.json['errors'][0]['detail']) + + def test_policy_disallow_get_all(self): + self._common_policy_check( + "cluster_template:get_all", self.get_json, '/clustertemplates', + expect_errors=True) + + def test_policy_disallow_get_one(self): + cluster_template = obj_utils.create_test_cluster_template(self.context) + self._common_policy_check( + "cluster_template:get", self.get_json, + '/clustertemplates/%s' % cluster_template.uuid, + expect_errors=True) + + def test_policy_disallow_detail(self): + self._common_policy_check( + "cluster_template:detail", self.get_json, + '/clustertemplates/%s/detail' % uuidutils.generate_uuid(), + expect_errors=True) + + def test_policy_disallow_update(self): + cluster_template = obj_utils.create_test_cluster_template( + self.context, + name='example_A', + uuid=uuidutils.generate_uuid()) + self._common_policy_check( + "cluster_template:update", self.patch_json, + '/clustertemplates/%s' % cluster_template.name, + [{'path': '/name', 'value': "new_name", 'op': 'replace'}], + expect_errors=True) + + def test_policy_disallow_create(self): + bdict = apiutils.cluster_template_post_data( + name='cluster_model_example_A') + self._common_policy_check( + "cluster_template:create", self.post_json, '/clustertemplates', + bdict, expect_errors=True) + + def test_policy_disallow_delete(self): + cluster_template = obj_utils.create_test_cluster_template(self.context) + self._common_policy_check( + "cluster_template:delete", self.delete, + '/clustertemplates/%s' % cluster_template.uuid, expect_errors=True) + + def _owner_check(self, rule, func, *args, **kwargs): + self.policy.set_rules({rule: "user_id:%(user_id)s"}) + response = func(*args, **kwargs) + self.assertEqual(403, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue( + "Policy doesn't allow %s to be performed." % rule, + response.json['errors'][0]['detail']) + + def test_policy_only_owner_get_one(self): + cluster_template = obj_utils.create_test_cluster_template( + self.context, + user_id='another') + self._owner_check("cluster_template:get", self.get_json, + '/clustertemplates/%s' % cluster_template.uuid, + expect_errors=True) + + def test_policy_only_owner_update(self): + cluster_template = obj_utils.create_test_cluster_template( + self.context, + user_id='another') + self._owner_check( + "cluster_template:update", self.patch_json, + '/clustertemplates/%s' % cluster_template.uuid, + [{'path': '/name', 'value': "new_name", 'op': 'replace'}], + expect_errors=True) + + def test_policy_only_owner_delete(self): + cluster_template = obj_utils.create_test_cluster_template( + self.context, + user_id='another') + self._owner_check( + "cluster_template:delete", self.delete, + '/clustertemplates/%s' % cluster_template.uuid, + expect_errors=True) diff --git a/magnum/tests/unit/api/utils.py b/magnum/tests/unit/api/utils.py index 382c10a5a4..be7ee407b4 100644 --- a/magnum/tests/unit/api/utils.py +++ b/magnum/tests/unit/api/utils.py @@ -18,6 +18,8 @@ import pytz from magnum.api.controllers.v1 import bay as bay_controller from magnum.api.controllers.v1 import baymodel as baymodel_controller +from magnum.api.controllers.v1 import cluster as cluster_controller +from magnum.api.controllers.v1 import cluster_template as cluster_tmp_ctrl from magnum.tests.unit.db import utils @@ -33,6 +35,12 @@ def baymodel_post_data(**kw): return remove_internal(baymodel, internal) +def cluster_template_post_data(**kw): + cluster_template = utils.get_test_baymodel(**kw) + internal = cluster_tmp_ctrl.ClusterTemplatePatchType.internal_attrs() + return remove_internal(cluster_template, internal) + + def bay_post_data(**kw): bay = utils.get_test_bay(**kw) bay['bay_create_timeout'] = kw.get('bay_create_timeout', 15) @@ -40,6 +48,17 @@ def bay_post_data(**kw): return remove_internal(bay, internal) +def cluster_post_data(**kw): + cluster = utils.get_test_bay(**kw) + cluster['create_timeout'] = kw.get('create_timeout', 15) + cluster['cluster_template_id'] = kw.get('cluster_template_id', + cluster['baymodel_id']) + del cluster['bay_create_timeout'] + del cluster['baymodel_id'] + internal = cluster_controller.ClusterPatchType.internal_attrs() + return remove_internal(cluster, internal) + + def cert_post_data(**kw): return { 'bay_uuid': kw.get('bay_uuid', '5d12f6fd-a196-4bf0-ae4c-1f639a523a52'), diff --git a/magnum/tests/unit/conductor/handlers/test_bay_conductor.py b/magnum/tests/unit/conductor/handlers/test_bay_conductor.py index 3385c26807..279273faeb 100644 --- a/magnum/tests/unit/conductor/handlers/test_bay_conductor.py +++ b/magnum/tests/unit/conductor/handlers/test_bay_conductor.py @@ -456,7 +456,7 @@ class TestHandler(db_base.DbTestCase): self.assertEqual(1, cert_manager.delete_certificates_from_bay.call_count) # The bay has been destroyed - self.assertRaises(exception.BayNotFound, + self.assertRaises(exception.ClusterNotFound, objects.Bay.get, self.context, self.bay.uuid) @patch('magnum.conductor.handlers.bay_conductor.cert_manager') diff --git a/magnum/tests/unit/db/test_bay.py b/magnum/tests/unit/db/test_bay.py index eadb67fb8e..61579ca9e8 100644 --- a/magnum/tests/unit/db/test_bay.py +++ b/magnum/tests/unit/db/test_bay.py @@ -34,7 +34,7 @@ class DbBayTestCase(base.DbTestCase): def test_create_bay_already_exists(self): utils.create_test_bay() - self.assertRaises(exception.BayAlreadyExists, + self.assertRaises(exception.ClusterAlreadyExists, utils.create_test_bay) def test_get_bay_by_id(self): @@ -56,10 +56,10 @@ class DbBayTestCase(base.DbTestCase): self.assertEqual(bay.uuid, res.uuid) def test_get_bay_that_does_not_exist(self): - self.assertRaises(exception.BayNotFound, + self.assertRaises(exception.ClusterNotFound, self.dbapi.get_bay_by_id, self.context, 999) - self.assertRaises(exception.BayNotFound, + self.assertRaises(exception.ClusterNotFound, self.dbapi.get_bay_by_uuid, self.context, '12345678-9999-0000-aaaa-123456789012') @@ -174,7 +174,7 @@ class DbBayTestCase(base.DbTestCase): self.assertIsNotNone(self.dbapi.get_bay_by_id(self.context, bay.id)) self.dbapi.destroy_bay(bay.id) - self.assertRaises(exception.BayNotFound, + self.assertRaises(exception.ClusterNotFound, self.dbapi.get_bay_by_id, self.context, bay.id) @@ -183,12 +183,12 @@ class DbBayTestCase(base.DbTestCase): self.assertIsNotNone(self.dbapi.get_bay_by_uuid(self.context, bay.uuid)) self.dbapi.destroy_bay(bay.uuid) - self.assertRaises(exception.BayNotFound, + self.assertRaises(exception.ClusterNotFound, self.dbapi.get_bay_by_uuid, self.context, bay.uuid) def test_destroy_bay_that_does_not_exist(self): - self.assertRaises(exception.BayNotFound, + self.assertRaises(exception.ClusterNotFound, self.dbapi.destroy_bay, '12345678-9999-0000-aaaa-123456789012') @@ -202,7 +202,7 @@ class DbBayTestCase(base.DbTestCase): def test_update_bay_not_found(self): bay_uuid = uuidutils.generate_uuid() - self.assertRaises(exception.BayNotFound, self.dbapi.update_bay, + self.assertRaises(exception.ClusterNotFound, self.dbapi.update_bay, bay_uuid, {'node_count': 5}) def test_update_bay_uuid(self): diff --git a/magnum/tests/unit/db/test_baymodel.py b/magnum/tests/unit/db/test_baymodel.py index 34b149ec39..b88ee4c424 100644 --- a/magnum/tests/unit/db/test_baymodel.py +++ b/magnum/tests/unit/db/test_baymodel.py @@ -100,7 +100,7 @@ class DbBaymodelTestCase(base.DbTestCase): self.assertEqual(bm['id'], baymodel.id) def test_get_baymodel_that_does_not_exist(self): - self.assertRaises(exception.BayModelNotFound, + self.assertRaises(exception.ClusterTemplateNotFound, self.dbapi.get_baymodel_by_id, self.context, 666) def test_get_baymodel_by_name(self): @@ -128,7 +128,7 @@ class DbBaymodelTestCase(base.DbTestCase): self.context, 'bm') def test_get_baymodel_by_name_not_found(self): - self.assertRaises(exception.BayModelNotFound, + self.assertRaises(exception.ClusterTemplateNotFound, self.dbapi.get_baymodel_by_name, self.context, 'not_found') @@ -138,7 +138,7 @@ class DbBaymodelTestCase(base.DbTestCase): self.assertEqual('updated-model', res.name) def test_update_baymodel_that_does_not_exist(self): - self.assertRaises(exception.BayModelNotFound, + self.assertRaises(exception.ClusterTemplateNotFound, self.dbapi.update_baymodel, 666, {'name': ''}) def test_update_baymodel_uuid(self): @@ -150,7 +150,7 @@ class DbBaymodelTestCase(base.DbTestCase): def test_destroy_baymodel(self): bm = utils.create_test_baymodel() self.dbapi.destroy_baymodel(bm['id']) - self.assertRaises(exception.BayModelNotFound, + self.assertRaises(exception.ClusterTemplateNotFound, self.dbapi.get_baymodel_by_id, self.context, bm['id']) @@ -160,23 +160,23 @@ class DbBaymodelTestCase(base.DbTestCase): self.assertIsNotNone(self.dbapi.get_baymodel_by_uuid(self.context, uuid)) self.dbapi.destroy_baymodel(uuid) - self.assertRaises(exception.BayModelNotFound, + self.assertRaises(exception.ClusterTemplateNotFound, self.dbapi.get_baymodel_by_uuid, self.context, uuid) def test_destroy_baymodel_that_does_not_exist(self): - self.assertRaises(exception.BayModelNotFound, + self.assertRaises(exception.ClusterTemplateNotFound, self.dbapi.destroy_baymodel, 666) def test_destroy_baymodel_that_referenced_by_bays(self): bm = utils.create_test_baymodel() bay = utils.create_test_bay(baymodel_id=bm['uuid']) self.assertEqual(bm['uuid'], bay.baymodel_id) - self.assertRaises(exception.BayModelReferenced, + self.assertRaises(exception.ClusterTemplateReferenced, self.dbapi.destroy_baymodel, bm['id']) def test_create_baymodel_already_exists(self): uuid = uuidutils.generate_uuid() utils.create_test_baymodel(id=1, uuid=uuid) - self.assertRaises(exception.BayModelAlreadyExists, + self.assertRaises(exception.ClusterTemplateAlreadyExists, utils.create_test_baymodel, id=2, uuid=uuid) diff --git a/magnum/tests/unit/objects/utils.py b/magnum/tests/unit/objects/utils.py index 7919365982..7f3b18f1d3 100644 --- a/magnum/tests/unit/objects/utils.py +++ b/magnum/tests/unit/objects/utils.py @@ -53,7 +53,7 @@ def create_test_baymodel(context, **kw): baymodel = get_test_baymodel(context, **kw) try: baymodel.create() - except exception.BayModelAlreadyExists: + except exception.ClusterTemplateAlreadyExists: baymodel = objects.BayModel.get(context, baymodel.uuid) return baymodel @@ -87,6 +87,42 @@ def create_test_bay(context, **kw): return bay +def get_test_cluster_template(context, **kw): + """Return a ClusterTemplate object with appropriate attributes. + + NOTE: Object model is the same for ClusterTemplate and + BayModel + """ + return get_test_baymodel(context, **kw) + + +def create_test_cluster_template(context, **kw): + """Create and return a test ClusterTemplate object. + + NOTE: Object model is the same for ClusterTemplate and + BayModel + """ + return create_test_baymodel(context, **kw) + + +def get_test_cluster(context, **kw): + """Return a Cluster object with appropriate attributes. + + NOTE: Object model is the same for Cluster and + Bay + """ + return get_test_bay(context, **kw) + + +def create_test_cluster(context, **kw): + """Create and return a test cluster object. + + NOTE: Object model is the same for Cluster and + Bay + """ + return create_test_bay(context, **kw) + + def get_test_x509keypair(context, **kw): """Return a X509KeyPair object with appropriate attributes.