diff --git a/magnum/api/attr_validator.py b/magnum/api/attr_validator.py index a056282ebe..a9986ee776 100644 --- a/magnum/api/attr_validator.py +++ b/magnum/api/attr_validator.py @@ -210,6 +210,33 @@ def validate_master_count(cluster, cluster_template): "master_count must be 1 when master_lb_enabled is False")) +def validate_federation_hostcluster(cluster_uuid): + """Validate Federation `hostcluster_id` parameter. + + If the parameter was not specified raise an + `exceptions.InvalidParameterValue`. If the specified identifier does not + identify any Cluster, raise `exception.ClusterNotFound` + """ + if cluster_uuid is not None: + api_utils.get_resource('Cluster', cluster_uuid) + else: + raise exception.InvalidParameterValue( + "No hostcluster specified. " + "Please specify a hostcluster_id.") + + +def validate_federation_properties(properties): + """Validate Federation `properties` parameter.""" + if properties is None: + raise exception.InvalidParameterValue( + "Please specify a `properties` " + "dict for the federation.") + # Currently, we only support the property `dns-zone`. + if properties.get('dns-zone') is None: + raise exception.InvalidParameterValue("No DNS zone specified. " + "Please specify a `dns-zone`.") + + # Dictionary that maintains a list of validation functions validators = {'image_id': validate_image, 'flavor_id': validate_flavor, diff --git a/magnum/api/controllers/v1/__init__.py b/magnum/api/controllers/v1/__init__.py index 3c99b4eebf..a3461961a3 100644 --- a/magnum/api/controllers/v1/__init__.py +++ b/magnum/api/controllers/v1/__init__.py @@ -29,6 +29,7 @@ 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 federation from magnum.api.controllers.v1 import magnum_services from magnum.api.controllers.v1 import quota from magnum.api.controllers.v1 import stats @@ -99,6 +100,9 @@ class V1(controllers_base.APIBase): stats = [link.Link] """Links to the stats resource""" + # Links to the federations resources + federations = [link.Link] + @staticmethod def convert(): v1 = V1() @@ -161,6 +165,13 @@ class V1(controllers_base.APIBase): pecan.request.host_url, 'stats', '', bookmark=True)] + v1.federations = [link.Link.make_link('self', pecan.request.host_url, + 'federations', ''), + link.Link.make_link('bookmark', + pecan.request.host_url, + 'federations', '', + bookmark=True)] + return v1 @@ -175,6 +186,7 @@ class Controller(controllers_base.Controller): certificates = certificate.CertificateController() mservices = magnum_services.MagnumServiceController() stats = stats.StatsController() + federations = federation.FederationsController() @expose.expose(V1) def get(self): diff --git a/magnum/api/controllers/v1/federation.py b/magnum/api/controllers/v1/federation.py new file mode 100644 index 0000000000..ea0addb7f6 --- /dev/null +++ b/magnum/api/controllers/v1/federation.py @@ -0,0 +1,454 @@ +# 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 +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 exception +from magnum.common import name_generator +from magnum.common import policy +import magnum.conf +from magnum import objects +from magnum.objects import fields + +LOG = logging.getLogger(__name__) +CONF = magnum.conf.CONF + + +class FederationID(wtypes.Base): + """API representation of a federation ID + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of a + federation ID. + """ + uuid = types.uuid + + def __init__(self, uuid): + self.uuid = uuid + + +class Federation(base.APIBase): + """API representation of a federation. + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of a + Federation. + """ + + # Unique UUID for this federation. + uuid = types.uuid + + # Name of this federation, max length is limited to 242 because heat stack + # requires max length limit to 255, and Magnum amend a uuid length. + name = wtypes.StringType(min_length=1, max_length=242, + pattern='^[a-zA-Z][a-zA-Z0-9_.-]*$') + + # UUID of the hostcluster of the federation, i.e. the cluster that + # hosts the COE Federated API. + hostcluster_id = wsme.wsattr(wtypes.text) + + # List of UUIDs of all the member clusters of the federation. + member_ids = wsme.wsattr([wtypes.text]) + + # Status of the federation. + status = wtypes.Enum(str, *fields.FederationStatus.ALL) + + # Status reason of the federation. + status_reason = wtypes.text + + # Set of federation metadata (COE-specific in some cases). + properties = wtypes.DictType(str, str) + + # A list containing a self link and associated federations links + links = wsme.wsattr([link.Link], readonly=True) + + def __init__(self, **kwargs): + super(Federation, self).__init__() + self.fields = [] + for field in objects.Federation.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(federation, url, expand=True): + if not expand: + federation.unset_fields_except(['uuid', 'name', 'hostcluster_id', + 'member_ids', 'status', + 'properties']) + + federation.links = [link.Link.make_link('self', url, 'federations', + federation.uuid), + link.Link.make_link('bookmark', url, 'federations', + federation.uuid, + bookmark=True)] + return federation + + @classmethod + def convert_with_links(cls, rpc_federation, expand=True): + federation = Federation(**rpc_federation.as_dict()) + return cls._convert_with_links(federation, pecan.request.host_url, + expand) + + @classmethod + def sample(cls, expand=True): + sample = cls(uuid='4221a353-8368-475f-b7de-3429d3f724b3', + name='example', + hostcluster_id='49dc23f5-ffc9-40c3-9d34-7be7f9e34d63', + member_ids=['49dc23f5-ffc9-40c3-9d34-7be7f9e34d63', + 'f2439bcf-02a2-4278-9d8a-f07a2042230a', + 'e549e0a5-3d3c-406f-bd7c-0e0182fb211c'], + properties={'dns-zone': 'example.com.'}, + status=fields.FederationStatus.CREATE_COMPLETE, + status_reason="CREATE completed successfully") + return cls._convert_with_links(sample, 'http://localhost:9511', expand) + + +class FederationPatchType(types.JsonPatchType): + _api_base = Federation + + @staticmethod + def internal_attrs(): + """"Returns a list of internal attributes. + + Internal attributes can't be added, replaced or removed. + """ + internal_attrs = [] + return types.JsonPatchType.internal_attrs() + internal_attrs + + +class FederationCollection(collection.Collection): + """API representation of a collection of federations.""" + + # A list containing federation objects. + federations = [Federation] + + def __init__(self, **kwargs): + self._type = 'federations' + + @staticmethod + def convert_with_links(rpc_federation, limit, url=None, expand=False, + **kwargs): + collection = FederationCollection() + collection.federations = [Federation.convert_with_links(p, expand) + for p in rpc_federation] + collection.next = collection.get_next(limit, url=url, **kwargs) + return collection + + @classmethod + def sample(cls): + sample = cls() + sample.federations = [Federation.sample(expand=False)] + return sample + + +class FederationsController(base.Controller): + """REST controller for federations.""" + + def __init__(self): + super(FederationsController, self).__init__() + + _custom_actions = { + 'detail': ['GET'], + } + + def _generate_name_for_federation(self, context): + """Generate a random name like: phi-17-federation.""" + name_gen = name_generator.NameGenerator() + name = name_gen.generate() + return name + '-federation' + + def _get_federation_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.Federation.get_by_uuid(pecan.request.context, + marker) + + federations = objects.Federation.list(pecan.request.context, limit, + marker_obj, sort_key=sort_key, + sort_dir=sort_dir) + + return FederationCollection.convert_with_links(federations, limit, + url=resource_url, + expand=expand, + sort_key=sort_key, + sort_dir=sort_dir) + + @expose.expose(FederationCollection, 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 federations. + + :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, 'federation:get_all', + action='federation:get_all') + return self._get_federation_collection(marker, limit, sort_key, + sort_dir) + + @expose.expose(FederationCollection, types.uuid, int, wtypes.text, + wtypes.text) + def detail(self, marker=None, limit=None, sort_key='id', + sort_dir='asc'): + """Retrieve a list of federation 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, 'federation:detail', + action='federation:detail') + + # NOTE(lucasagomes): /detail should only work against collections + parent = pecan.request.path.split('/')[:-1][-1] + if parent != "federations": + raise exception.HTTPNotFound + + expand = True + resource_url = '/'.join(['federations', 'detail']) + return self._get_federation_collection(marker, limit, + sort_key, sort_dir, expand, + resource_url) + + @expose.expose(Federation, types.uuid_or_name) + def get_one(self, federation_ident): + """Retrieve information about a given Federation. + + :param federation_ident: UUID or logical name of the Federation. + """ + context = pecan.request.context + federation = api_utils.get_resource('Federation', federation_ident) + policy.enforce(context, 'federation:get', federation.as_dict(), + action='federation:get') + + federation = Federation.convert_with_links(federation) + + return federation + + @expose.expose(FederationID, body=Federation, status_code=202) + def post(self, federation): + """Create a new federation. + + :param federation: a federation within the request body. + """ + context = pecan.request.context + policy.enforce(context, 'federation:create', + action='federation:create') + + federation_dict = federation.as_dict() + + # Validate `hostcluster_id` + hostcluster_id = federation_dict.get('hostcluster_id') + attr_validator.validate_federation_hostcluster(hostcluster_id) + + # Validate `properties` dict. + properties_dict = federation_dict.get('properties') + attr_validator.validate_federation_properties(properties_dict) + + federation_dict['project_id'] = context.project_id + + # If no name is specified, generate a random human-readable name + name = (federation_dict.get('name') or + self._generate_name_for_federation(context)) + federation_dict['name'] = name + + new_federation = objects.Federation(context, **federation_dict) + new_federation.uuid = uuid.uuid4() + + # TODO(clenimar): remove hard-coded `create_timeout`. + pecan.request.rpcapi.federation_create_async(new_federation, + create_timeout=15) + + return FederationID(new_federation.uuid) + + @expose.expose(FederationID, types.uuid_or_name, types.boolean, + body=[FederationPatchType], status_code=202) + def patch(self, federation_ident, rollback=False, patch=None): + """Update an existing Federation. + + Please note that the join/unjoin operation is performed by patching + `member_ids`. + + :param federation_ident: UUID or logical name of a federation. + :param rollback: whether to rollback federation on update failure. + :param patch: a json PATCH document to apply to this federation. + """ + federation = self._patch(federation_ident, patch) + pecan.request.rpcapi.federation_update_async(federation, rollback) + return FederationID(federation.uuid) + + def _patch(self, federation_ident, patch): + context = pecan.request.context + federation = api_utils.get_resource('Federation', federation_ident) + policy.enforce(context, 'federation:update', federation.as_dict(), + action='federation:update') + + # NOTE(clenimar): Magnum does not allow one to append items to existing + # fields through an `add` operation using HTTP PATCH (please check + # `magnum.api.utils.apply_jsonpatch`). In order to perform the join + # and unjoin operations, intercept the original JSON PATCH document + # and change the operation from either `add` or `remove` to `replace`. + patch_path = patch[0].get('path') + patch_value = patch[0].get('value') + patch_op = patch[0].get('op') + + if patch_path == '/member_ids': + if patch_op == 'add' and patch_value is not None: + patch = self._join_wrapper(federation_ident, patch) + elif patch_op == 'remove' and patch_value is not None: + patch = self._unjoin_wrapper(federation_ident, patch) + + try: + federation_dict = federation.as_dict() + new_federation = Federation( + **api_utils.apply_jsonpatch(federation_dict, patch)) + except api_utils.JSONPATCH_EXCEPTIONS as e: + raise exception.PatchError(patch=patch, reason=e) + + # Retrieve only what changed after the patch. + delta = self._update_changed_fields(federation, new_federation) + validation.validate_federation_properties(delta) + + return federation + + def _update_changed_fields(self, federation, new_federation): + """Update only the patches that were modified and return the diff.""" + for field in objects.Federation.fields: + try: + patch_val = getattr(new_federation, field) + except AttributeError: + # Ignore fields that aren't exposed in the API + continue + if patch_val == wtypes.Unset: + patch_val = None + if federation[field] != patch_val: + federation[field] = patch_val + + return federation.obj_what_changed() + + def _join_wrapper(self, federation_ident, patch): + """Intercept PATCH JSON documents for join operations. + + Take a PATCH JSON document with `add` operation:: + { + 'op': 'add', + 'value': 'new_member_id', + 'path': '/member_ids' + } + and transform it into a document with `replace` operation:: + { + 'op': 'replace', + 'value': ['current_member_id1', ..., 'new_member_id'], + 'path': '/member_ids' + } + """ + federation = api_utils.get_resource('Federation', federation_ident) + new_member_uuid = patch[0]['value'] + + # Check if the cluster exists + c = objects.Cluster.get_by_uuid(pecan.request.context, new_member_uuid) + + # Check if the cluster is already a member of the federation + if new_member_uuid not in federation.member_ids and c is not None: + # Retrieve all current members + members = federation.member_ids + # Add the new member + members.append(c.uuid) + else: + kw = {'uuid': new_member_uuid, 'federation_name': federation.name} + raise exception.MemberAlreadyExists(**kw) + + # Set `value` to the updated member list. Change `op` to `replace` + patch[0]['value'] = members + patch[0]['op'] = 'replace' + + return patch + + def _unjoin_wrapper(self, federation_ident, patch): + """Intercept PATCH JSON documents for unjoin operations. + + Take a PATCH JSON document with `remove` operation:: + { + 'op': 'remove', + 'value': 'former_member_id', + 'path': '/member_ids' + } + and transform it into a document with `replace` operation:: + { + 'op': 'replace', + 'value': ['current_member_id1', ..., 'current_member_idn'], + 'path': '/member_ids' + } + """ + federation = api_utils.get_resource('Federation', federation_ident) + cluster_uuid = patch[0]['value'] + + # Check if the cluster exists + c = objects.Cluster.get_by_uuid(pecan.request.context, cluster_uuid) + + # Check if the cluster is a member cluster and if it exists + if cluster_uuid in federation.member_ids and c is not None: + # Retrieve all current members + members = federation.member_ids + # Unjoin the member + members.remove(cluster_uuid) + else: + raise exception.HTTPNotFound("Cluster %s is not a member of the " + "federation %s." % (cluster_uuid, + federation.name)) + + # Set `value` to the updated member list. Change `op` to `replace` + patch[0]['value'] = members + patch[0]['op'] = 'replace' + + return patch + + @expose.expose(None, types.uuid_or_name, status_code=204) + def delete(self, federation_ident): + """Delete a federation. + + :param federation_ident: UUID of federation or logical name + of the federation. + """ + context = pecan.request.context + federation = api_utils.get_resource('Federation', federation_ident) + policy.enforce(context, 'federation:delete', federation.as_dict(), + action='federation:delete') + + pecan.request.rpcapi.federation_delete_async(federation.uuid) diff --git a/magnum/api/validation.py b/magnum/api/validation.py index ff852df3c3..4cc325a12f 100644 --- a/magnum/api/validation.py +++ b/magnum/api/validation.py @@ -30,6 +30,7 @@ from magnum import objects CONF = magnum.conf.CONF cluster_update_allowed_properties = set(['node_count']) +federation_update_allowed_properties = set(['member_ids', 'properties']) def enforce_cluster_type_supported(): @@ -200,6 +201,15 @@ def validate_cluster_properties(delta): raise exception.InvalidParameterValue(err=err) +def validate_federation_properties(delta): + + update_disallowed_properties = delta - federation_update_allowed_properties + if update_disallowed_properties: + err = (_("cannot change federation property(ies) %s.") % + ", ".join(update_disallowed_properties)) + raise exception.InvalidParameterValue(err=err) + + class Validator(object): @classmethod diff --git a/magnum/cmd/conductor.py b/magnum/cmd/conductor.py index d0eb03f9df..4cf3f6f5be 100755 --- a/magnum/cmd/conductor.py +++ b/magnum/cmd/conductor.py @@ -28,6 +28,7 @@ from magnum.common import short_id from magnum.conductor.handlers import ca_conductor from magnum.conductor.handlers import cluster_conductor from magnum.conductor.handlers import conductor_listener +from magnum.conductor.handlers import federation_conductor from magnum.conductor.handlers import indirection_api import magnum.conf from magnum import version @@ -51,6 +52,7 @@ def main(): cluster_conductor.Handler(), conductor_listener.Handler(), ca_conductor.Handler(), + federation_conductor.Handler(), ] server = rpc_service.Service.create(CONF.conductor.topic, diff --git a/magnum/common/exception.py b/magnum/common/exception.py index b99c20a17d..e76f7f16a5 100755 --- a/magnum/common/exception.py +++ b/magnum/common/exception.py @@ -382,3 +382,8 @@ class FederationNotFound(ResourceNotFound): class FederationAlreadyExists(Conflict): message = _("A federation with UUID %(uuid)s already exists.") + + +class MemberAlreadyExists(Conflict): + message = _("A cluster with UUID %(uuid)s is already a member of the" + "federation %(federation_name)s.") diff --git a/magnum/common/policies/__init__.py b/magnum/common/policies/__init__.py index 0722800fc5..008bb91f84 100644 --- a/magnum/common/policies/__init__.py +++ b/magnum/common/policies/__init__.py @@ -20,6 +20,7 @@ from magnum.common.policies import baymodel from magnum.common.policies import certificate from magnum.common.policies import cluster from magnum.common.policies import cluster_template +from magnum.common.policies import federation from magnum.common.policies import magnum_service from magnum.common.policies import quota from magnum.common.policies import stats @@ -33,6 +34,7 @@ def list_rules(): certificate.list_rules(), cluster.list_rules(), cluster_template.list_rules(), + federation.list_rules(), magnum_service.list_rules(), quota.list_rules(), stats.list_rules() diff --git a/magnum/common/policies/federation.py b/magnum/common/policies/federation.py new file mode 100644 index 0000000000..b78b1a1b1e --- /dev/null +++ b/magnum/common/policies/federation.py @@ -0,0 +1,91 @@ +# 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_policy import policy + +from magnum.common.policies import base + +FEDERATION = 'federation:%s' + +rules = [ + policy.DocumentedRuleDefault( + name=FEDERATION % 'create', + check_str=base.RULE_DENY_CLUSTER_USER, + description='Create a new federation.', + operations=[ + { + 'path': '/v1/federations', + 'method': 'POST' + } + ] + ), + policy.DocumentedRuleDefault( + name=FEDERATION % 'delete', + check_str=base.RULE_DENY_CLUSTER_USER, + description='Delete a federation.', + operations=[ + { + 'path': '/v1/federations/{federation_ident}', + 'method': 'DELETE' + } + ] + ), + policy.DocumentedRuleDefault( + name=FEDERATION % 'detail', + check_str=base.RULE_DENY_CLUSTER_USER, + description='Retrieve a list of federations with detail.', + operations=[ + { + 'path': '/v1/federations', + 'method': 'GET' + } + ] + ), + policy.DocumentedRuleDefault( + name=FEDERATION % 'get', + check_str=base.RULE_DENY_CLUSTER_USER, + description='Retrieve information about the given federation.', + operations=[ + { + 'path': '/v1/federations/{federation_ident}', + 'method': 'GET' + } + ] + ), + policy.DocumentedRuleDefault( + name=FEDERATION % 'get_all', + check_str=base.RULE_DENY_CLUSTER_USER, + description='Retrieve a list of federations.', + operations=[ + { + 'path': '/v1/federations/', + 'method': 'GET' + } + ] + ), + policy.DocumentedRuleDefault( + name=FEDERATION % 'update', + check_str=base.RULE_DENY_CLUSTER_USER, + description='Update an existing federation.', + operations=[ + { + 'path': '/v1/federations/{federation_ident}', + 'method': 'PATCH' + } + ] + ) +] + + +def list_rules(): + return rules diff --git a/magnum/conductor/api.py b/magnum/conductor/api.py index 9295f31efd..7b0d2c0fb7 100644 --- a/magnum/conductor/api.py +++ b/magnum/conductor/api.py @@ -51,6 +51,29 @@ class API(rpc_service.API): def cluster_update_async(self, cluster, rollback=False): self._cast('cluster_update', cluster=cluster, rollback=rollback) + # Federation Operations + + def federation_create(self, federation, create_timeout): + return self._call('federation_create', federation=federation, + create_timeout=create_timeout) + + def federation_create_async(self, federation, create_timeout): + self._cast('federation_create', federation=federation, + create_timeout=create_timeout) + + def federation_delete(self, uuid): + return self._call('federation_delete', uuid=uuid) + + def federation_delete_async(self, uuid): + self._cast('federation_delete', uuid=uuid) + + def federation_update(self, federation): + return self._call('federation_update', federation=federation) + + def federation_update_async(self, federation, rollback=False): + self._cast('federation_update', federation=federation, + rollback=rollback) + # CA operations def sign_certificate(self, cluster, certificate): diff --git a/magnum/conductor/handlers/federation_conductor.py b/magnum/conductor/handlers/federation_conductor.py new file mode 100644 index 0000000000..f6cd43b75d --- /dev/null +++ b/magnum/conductor/handlers/federation_conductor.py @@ -0,0 +1,32 @@ +# 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.common import profiler +import magnum.conf + +CONF = magnum.conf.CONF + + +@profiler.trace_cls("rpc") +class Handler(object): + + def __init__(self): + super(Handler, self).__init__() + + def federation_create(self, context, federation, create_timeout): + raise NotImplementedError("This feature is not yet implemented.") + + def federation_update(self, context, federation, rollback=False): + raise NotImplementedError("This feature is not yet implemented.") + + def federation_delete(self, context, uuid): + raise NotImplementedError("This feature is not yet implemented.") diff --git a/magnum/drivers/common/driver.py b/magnum/drivers/common/driver.py index 9206204782..d925ee305b 100644 --- a/magnum/drivers/common/driver.py +++ b/magnum/drivers/common/driver.py @@ -173,6 +173,21 @@ class Driver(object): raise NotImplementedError("Subclasses must implement " "'delete_cluster'.") + @abc.abstractmethod + def create_federation(self, context, federation): + raise NotImplementedError("Subclasses must implement " + "'create_federation'.") + + @abc.abstractmethod + def update_federation(self, context, federation): + raise NotImplementedError("Subclasses must implement " + "'update_federation'.") + + @abc.abstractmethod + def delete_federation(self, context, federation): + raise NotImplementedError("Subclasses must implement " + "'delete_federation'.") + def get_monitor(self, context, cluster): """return the monitor with container data for this driver.""" diff --git a/magnum/drivers/heat/driver.py b/magnum/drivers/heat/driver.py index 7051b94374..91059da225 100755 --- a/magnum/drivers/heat/driver.py +++ b/magnum/drivers/heat/driver.py @@ -74,6 +74,15 @@ class HeatDriver(driver.Driver): raise NotImplementedError("Must implement 'get_template_definition'") + def create_federation(self, context, federation): + return NotImplementedError("Must implement 'create_federation'") + + def update_federation(self, context, federation): + return NotImplementedError("Must implement 'update_federation'") + + def delete_federation(self, context, federation): + return NotImplementedError("Must implement 'delete_federation'") + def update_cluster_status(self, context, cluster): if cluster.stack_id is None: # NOTE(mgoddard): During cluster creation it is possible to poll diff --git a/magnum/objects/__init__.py b/magnum/objects/__init__.py index 6f33ab6a7d..9371d3d67a 100644 --- a/magnum/objects/__init__.py +++ b/magnum/objects/__init__.py @@ -15,6 +15,7 @@ from magnum.objects import certificate from magnum.objects import cluster from magnum.objects import cluster_template +from magnum.objects import federation from magnum.objects import magnum_service from magnum.objects import quota from magnum.objects import stats @@ -28,10 +29,13 @@ Quota = quota.Quota X509KeyPair = x509keypair.X509KeyPair Certificate = certificate.Certificate Stats = stats.Stats +Federation = federation.Federation __all__ = (Cluster, ClusterTemplate, MagnumService, X509KeyPair, Certificate, Stats, - Quota) + Quota, + Federation + ) diff --git a/magnum/objects/federation.py b/magnum/objects/federation.py new file mode 100644 index 0000000000..decb6dbe1b --- /dev/null +++ b/magnum/objects/federation.py @@ -0,0 +1,215 @@ +# 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 strutils +from oslo_utils import uuidutils +from oslo_versionedobjects import fields + +from magnum.common import exception +from magnum.db import api as dbapi +from magnum.objects import base +from magnum.objects import fields as m_fields + + +@base.MagnumObjectRegistry.register +class Federation(base.MagnumPersistentObject, base.MagnumObject, + base.MagnumObjectDictCompat): + """Represents a Federation object. + + Version 1.0: Initial Version + """ + + VERSION = '1.0' + + dbapi = dbapi.get_instance() + + fields = { + 'id': fields.IntegerField(), + 'uuid': fields.UUIDField(nullable=True), + 'name': fields.StringField(nullable=True), + 'project_id': fields.StringField(nullable=True), + 'hostcluster_id': fields.StringField(nullable=True), + 'member_ids': fields.ListOfStringsField(nullable=True), + 'status': m_fields.FederationStatusField(nullable=True), + 'status_reason': fields.StringField(nullable=True), + 'properties': fields.DictOfStringsField(nullable=True) + } + + @staticmethod + def _from_db_object(federation, db_federation): + """Converts a database entity to a formal object.""" + for field in federation.fields: + federation[field] = db_federation[field] + + federation.obj_reset_changes() + return federation + + @staticmethod + def _from_db_object_list(db_objects, cls, context): + """Converts a list of database entities to a list of formal objects.""" + return [Federation._from_db_object(cls(context), obj) + for obj in db_objects] + + @base.remotable_classmethod + def get(cls, context, federation_id): + """Find a federation based on its id or uuid and return it. + + :param federation_id: the id *or* uuid of a federation. + :param context: Security context + :returns: a :class:`Federation` object. + """ + if strutils.is_int_like(federation_id): + return cls.get_by_id(context, federation_id) + elif uuidutils.is_uuid_like(federation_id): + return cls.get_by_uuid(context, federation_id) + else: + raise exception.InvalidIdentity(identity=federation_id) + + @base.remotable_classmethod + def get_by_id(cls, context, federation_id): + """Find a federation based on its integer id and return it. + + :param federation_id: the id of a federation. + :param context: Security context + :returns: a :class:`Federation` object. + """ + db_federation = cls.dbapi.get_federation_by_id(context, federation_id) + federation = Federation._from_db_object(cls(context), db_federation) + return federation + + @base.remotable_classmethod + def get_by_uuid(cls, context, uuid): + """Find a federation based on uuid and return it. + + :param uuid: the uuid of a federation. + :param context: Security context + :returns: a :class:`Federation` object. + """ + db_federation = cls.dbapi.get_federation_by_uuid(context, uuid) + federation = Federation._from_db_object(cls(context), db_federation) + return federation + + @base.remotable_classmethod + def get_count_all(cls, context, filters=None): + """Get count of matching federation. + + :param context: The security context + :param filters: filter dict, can includes 'name', 'project_id', + 'hostcluster_id', 'member_ids', 'status' (should be a + status list). + :returns: Count of matching federation. + """ + return cls.dbapi.get_federation_count_all(context, filters=filters) + + @base.remotable_classmethod + def get_by_name(cls, context, name): + """Find a federation based on name and return a Federation object. + + :param name: the logical name of a federation. + :param context: Security context + :returns: a :class:`Federation` object. + """ + db_federation = cls.dbapi.get_federation_by_name(context, name) + federation = Federation._from_db_object(cls(context), db_federation) + return federation + + @base.remotable_classmethod + def list(cls, context, limit=None, marker=None, + sort_key=None, sort_dir=None, filters=None): + """Return a list of Federation objects. + + :param context: Security context. + :param limit: maximum number of resources to return in a single result. + :param marker: pagination marker for large data sets. + :param sort_key: column to sort results by. + :param sort_dir: direction to sort. "asc" or "desc". + :param filters: filter dict, can includes 'name', 'project_id', + 'hostcluster_id', 'member_ids', 'status' (should be a + status list). + :returns: a list of :class:`Federation` object. + + """ + db_federation = cls.dbapi.get_federation_list(context, limit=limit, + marker=marker, + sort_key=sort_key, + sort_dir=sort_dir, + filters=filters) + return Federation._from_db_object_list(db_federation, cls, context) + + @base.remotable + def create(self, context=None): + """Create a Federation record in the DB. + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Federation(context) + + """ + values = self.obj_get_changes() + db_federation = self.dbapi.create_federation(values) + self._from_db_object(self, db_federation) + + @base.remotable + def destroy(self, context=None): + """Delete the Federation from the DB. + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Federation(context) + """ + self.dbapi.destroy_federation(self.uuid) + self.obj_reset_changes() + + @base.remotable + def save(self, context=None): + """Save updates to this Federation. + + Updates will be made column by column based on the result + of self.what_changed(). + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Federation(context) + """ + updates = self.obj_get_changes() + self.dbapi.update_federation(self.uuid, updates) + + self.obj_reset_changes() + + @base.remotable + def refresh(self, context=None): + """Load updates for this Federation. + + Loads a Federation with the same uuid from the database and + checks for updated attributes. Updates are applied from + the loaded Federation column by column, if there are any updates. + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Federation(context) + """ + current = self.__class__.get_by_uuid(self._context, uuid=self.uuid) + for field in self.fields: + if self.obj_attr_is_set(field) and self[field] != current[field]: + self[field] = current[field] diff --git a/magnum/objects/fields.py b/magnum/objects/fields.py index 9024ef33d0..07b3a48632 100644 --- a/magnum/objects/fields.py +++ b/magnum/objects/fields.py @@ -49,6 +49,28 @@ class ClusterStatus(fields.Enum): super(ClusterStatus, self).__init__(valid_values=ClusterStatus.ALL) +class FederationStatus(fields.Enum): + CREATE_IN_PROGRESS = 'CREATE_IN_PROGRESS' + CREATE_FAILED = 'CREATE_FAILED' + CREATE_COMPLETE = 'CREATE_COMPLETE' + UPDATE_IN_PROGRESS = 'UPDATE_IN_PROGRESS' + UPDATE_FAILED = 'UPDATE_FAILED' + UPDATE_COMPLETE = 'UPDATE_COMPLETE' + DELETE_IN_PROGRESS = 'DELETE_IN_PROGRESS' + DELETE_FAILED = 'DELETE_FAILED' + DELETE_COMPLETE = 'DELETE_COMPLETE' + + ALL = (CREATE_IN_PROGRESS, CREATE_FAILED, CREATE_COMPLETE, + UPDATE_IN_PROGRESS, UPDATE_FAILED, UPDATE_COMPLETE, + DELETE_IN_PROGRESS, DELETE_FAILED, DELETE_COMPLETE) + + STATUS_FAILED = (CREATE_FAILED, UPDATE_FAILED, DELETE_FAILED) + + def __init__(self): + super(FederationStatus, self).__init__( + valid_values=FederationStatus.ALL) + + class ContainerStatus(fields.Enum): ALL = ( ERROR, RUNNING, STOPPED, PAUSED, UNKNOWN, @@ -146,3 +168,7 @@ class ClusterTypeField(fields.BaseEnumField): class ServerTypeField(fields.BaseEnumField): AUTO_TYPE = ServerType() + + +class FederationStatusField(fields.BaseEnumField): + AUTO_TYPE = FederationStatus() diff --git a/magnum/tests/unit/api/controllers/test_root.py b/magnum/tests/unit/api/controllers/test_root.py index 789e1041c3..68faaf9069 100644 --- a/magnum/tests/unit/api/controllers/test_root.py +++ b/magnum/tests/unit/api/controllers/test_root.py @@ -86,7 +86,11 @@ class TestRootController(api_base.FunctionalTest): u'mservices': [{u'href': u'http://localhost/v1/mservices/', u'rel': u'self'}, {u'href': u'http://localhost/mservices/', - u'rel': u'bookmark'}]} + u'rel': u'bookmark'}], + u'federations': [{u'href': u'http://localhost/v1/federations/', + u'rel': u'self'}, + {u'href': u'http://localhost/federations/', + u'rel': u'bookmark'}]} def make_app(self, paste_file): file_name = self.get_path(paste_file) diff --git a/magnum/tests/unit/api/controllers/v1/test_federation.py b/magnum/tests/unit/api/controllers/v1/test_federation.py new file mode 100644 index 0000000000..c685b15e90 --- /dev/null +++ b/magnum/tests/unit/api/controllers/v1/test_federation.py @@ -0,0 +1,415 @@ +# 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 uuidutils + +from magnum.api.controllers.v1 import federation as api_federation +from magnum.conductor import api as rpcapi +import magnum.conf +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 + +CONF = magnum.conf.CONF + + +class TestFederationObject(base.TestCase): + def test_federation_init(self): + fed_dict = apiutils.federation_post_data() + fed_dict['uuid'] = uuidutils.generate_uuid() + federation = api_federation.Federation(**fed_dict) + self.assertEqual(fed_dict['uuid'], federation.uuid) + + +class TestListFederation(api_base.FunctionalTest): + def setUp(self): + super(TestListFederation, self).setUp() + + def test_empty(self): + response = self.get_json('/federations') + self.assertEqual(response['federations'], []) + + def test_one(self): + federation = obj_utils.create_test_federation( + self.context, uuid=uuidutils.generate_uuid()) + response = self.get_json('/federations') + self.assertEqual(federation.uuid, response['federations'][0]['uuid']) + + def test_get_one(self): + federation = obj_utils.create_test_federation( + self.context, uuid=uuidutils.generate_uuid()) + response = self.get_json('/federations/%s' % federation['uuid']) + self.assertTrue(response['uuid'], federation.uuid) + + def test_get_one_by_name(self): + federation = obj_utils.create_test_federation( + self.context, uuid=uuidutils.generate_uuid()) + response = self.get_json('/federations/%s' % federation['name']) + self.assertTrue(response['uuid'], federation.uuid) + + def test_get_one_by_name_not_found(self): + response = self.get_json('/federations/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_uuid(self): + temp_uuid = uuidutils.generate_uuid() + federation = obj_utils.create_test_federation(self.context, + uuid=temp_uuid) + response = self.get_json('/federations/%s' % temp_uuid) + self.assertTrue(response['uuid'], federation.uuid) + + def test_get_one_by_uuid_not_found(self): + temp_uuid = uuidutils.generate_uuid() + response = self.get_json('/federations/%s' % temp_uuid, + 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_federation(self): + obj_utils.create_test_federation(self.context, name='test_federation', + uuid=uuidutils.generate_uuid()) + obj_utils.create_test_federation(self.context, name='test_federation', + uuid=uuidutils.generate_uuid()) + response = self.get_json('/federations/test_federation', + 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): + federation_list = [] + for id_ in range(4): + federation = obj_utils.create_test_federation( + self.context, id=id_, uuid=uuidutils.generate_uuid()) + federation_list.append(federation) + + response = self.get_json( + '/federations?limit=3&marker=%s' % federation_list[2].uuid) + self.assertEqual(1, len(response['federations'])) + self.assertEqual(federation_list[-1].uuid, + response['federations'][0]['uuid']) + + def test_detail(self): + federation = obj_utils.create_test_federation( + self.context, uuid=uuidutils.generate_uuid()) + response = self.get_json('/federations/detail') + self.assertEqual(federation.uuid, response['federations'][0]["uuid"]) + + def test_detail_with_pagination_marker(self): + federation_list = [] + for id_ in range(4): + federation = obj_utils.create_test_federation( + self.context, id=id_, uuid=uuidutils.generate_uuid()) + federation_list.append(federation) + + response = self.get_json( + '/federations/detail?limit=3&marker=%s' % federation_list[2].uuid) + self.assertEqual(1, len(response['federations'])) + self.assertEqual(federation_list[-1].uuid, + response['federations'][0]['uuid']) + + def test_detail_against_single(self): + federation = obj_utils.create_test_federation( + self.context, uuid=uuidutils.generate_uuid()) + response = self.get_json( + '/federations/%s/detail' % federation['uuid'], expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_many(self): + federation_list = [] + for id_ in range(5): + temp_uuid = uuidutils.generate_uuid() + federation = obj_utils.create_test_federation( + self.context, id=id_, uuid=temp_uuid) + federation_list.append(federation.uuid) + + response = self.get_json('/federations') + self.assertEqual(len(federation_list), len(response['federations'])) + uuids = [f['uuid'] for f in response['federations']] + self.assertEqual(sorted(federation_list), sorted(uuids)) + + def test_links(self): + uuid = uuidutils.generate_uuid() + obj_utils.create_test_federation(self.context, id=1, uuid=uuid) + response = self.get_json('/federations/%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_federation(self.context, id=id_, + uuid=uuidutils.generate_uuid()) + response = self.get_json('/federations/?limit=3') + next_marker = response['federations'][-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_federation(self.context, id=id_, + uuid=uuidutils.generate_uuid()) + response = self.get_json('/federations') + self.assertEqual(3, len(response['federations'])) + + next_marker = response['federations'][-1]['uuid'] + self.assertIn(next_marker, response['next']) + + +class TestPatch(api_base.FunctionalTest): + def setUp(self): + super(TestPatch, self).setUp() + p = mock.patch.object(rpcapi.API, 'federation_update_async') + self.mock_federation_update = p.start() + self.mock_federation_update.side_effect = \ + self._sim_rpc_federation_update + self.addCleanup(p.stop) + + def _sim_rpc_federation_update(self, federation, rollback=False): + federation.save() + return federation + + def test_member_join(self): + f = obj_utils.create_test_federation( + self.context, name='federation-example', + uuid=uuidutils.generate_uuid(), member_ids=[]) + new_member = obj_utils.create_test_cluster(self.context) + + response = self.patch_json( + '/federations/%s' % f.uuid, + [{'path': '/member_ids', 'value': new_member.uuid, 'op': 'add'}]) + self.assertEqual(202, response.status_int) + + # make sure it was added: + fed = self.get_json('/federations/%s' % f.uuid) + self.assertTrue(new_member.uuid in fed['member_ids']) + + def test_member_unjoin(self): + member = obj_utils.create_test_cluster(self.context) + federation = obj_utils.create_test_federation( + self.context, name='federation-example', + uuid=uuidutils.generate_uuid(), member_ids=[member.uuid]) + + response = self.patch_json( + '/federations/%s' % federation.uuid, + [{'path': '/member_ids', 'value': member.uuid, 'op': 'remove'}]) + self.assertEqual(202, response.status_int) + + # make sure it was deleted: + fed = self.get_json('/federations/%s' % federation.uuid) + self.assertFalse(member.uuid in fed['member_ids']) + + def test_join_non_existent_cluster(self): + foo_uuid = uuidutils.generate_uuid() + f = obj_utils.create_test_federation( + self.context, name='federation-example', + uuid=uuidutils.generate_uuid(), member_ids=[]) + + response = self.patch_json( + '/federations/%s' % f.uuid, + [{'path': '/member_ids', 'value': foo_uuid, 'op': 'add'}], + expect_errors=True) + self.assertEqual(404, response.status_int) + + def test_unjoin_non_existent_cluster(self): + foo_uuid = uuidutils.generate_uuid() + f = obj_utils.create_test_federation( + self.context, name='federation-example', + uuid=uuidutils.generate_uuid(), member_ids=[]) + + response = self.patch_json( + '/federations/%s' % f.uuid, + [{'path': '/member_ids', 'value': foo_uuid, 'op': 'remove'}], + expect_errors=True) + self.assertEqual(404, response.status_int) + + def test_join_cluster_already_member(self): + cluster = obj_utils.create_test_cluster(self.context) + f = obj_utils.create_test_federation( + self.context, name='federation-example', + uuid=uuidutils.generate_uuid(), member_ids=[cluster.uuid]) + + response = self.patch_json( + '/federations/%s' % f.uuid, + [{'path': '/member_ids', 'value': cluster.uuid, 'op': 'add'}], + expect_errors=True) + self.assertEqual(409, response.status_int) + + def test_unjoin_non_member_cluster(self): + cluster = obj_utils.create_test_cluster(self.context) + f = obj_utils.create_test_federation( + self.context, name='federation-example', + uuid=uuidutils.generate_uuid(), member_ids=[]) + + response = self.patch_json( + '/federations/%s' % f.uuid, + [{'path': '/member_ids', 'value': cluster.uuid, 'op': 'remove'}], + expect_errors=True) + self.assertEqual(404, response.status_int) + + +class TestPost(api_base.FunctionalTest): + def setUp(self): + super(TestPost, self).setUp() + p = mock.patch.object(rpcapi.API, 'federation_create_async') + self.mock_fed_create = p.start() + self.mock_fed_create.side_effect = self._simulate_federation_create + self.addCleanup(p.stop) + self.hostcluster = obj_utils.create_test_cluster(self.context) + + def _simulate_federation_create(self, federation, create_timeout): + federation.create() + return federation + + @mock.patch('oslo_utils.timeutils.utcnow') + def test_create_federation(self, mock_utcnow): + bdict = apiutils.federation_post_data( + uuid=uuidutils.generate_uuid(), + hostcluster_id=self.hostcluster.uuid) + test_time = datetime.datetime(2000, 1, 1, 0, 0) + mock_utcnow.return_value = test_time + + response = self.post_json('/federations', 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_federation_no_hostcluster_id(self): + bdict = apiutils.federation_post_data(uuid=uuidutils.generate_uuid()) + del bdict['hostcluster_id'] + response = self.post_json('/federations', bdict, expect_errors=True) + self.assertEqual(400, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_create_federation_hostcluster_does_not_exist(self): + bdict = apiutils.federation_post_data( + uuid=uuidutils.generate_uuid(), + hostcluster_id=uuidutils.generate_uuid()) + response = self.post_json('/federations', bdict, expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_create_federation_no_dns_zone_name(self): + bdict = apiutils.federation_post_data( + uuid=uuidutils.generate_uuid(), + hostcluster_id=self.hostcluster.uuid) + del bdict['properties'] + response = self.post_json('/federations', bdict, expect_errors=True) + self.assertEqual(400, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['errors']) + + def test_create_federation_generate_uuid(self): + bdict = apiutils.federation_post_data( + hostcluster_id=self.hostcluster.uuid) + del bdict['uuid'] + response = self.post_json('/federations', bdict) + self.assertEqual(202, response.status_int) + + def test_create_federation_with_invalid_name(self): + invalid_names = [ + 'x' * 243, '123456', '123456test_federation', + '-test_federation', '.test_federation', '_test_federation', '' + ] + + for value in invalid_names: + bdict = apiutils.federation_post_data( + uuid=uuidutils.generate_uuid(), name=value, + hostcluster_id=self.hostcluster.uuid) + response = self.post_json('/federations', 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_federation_with_valid_name(self): + valid_names = [ + 'test_federation123456', 'test-federation', 'test.federation', + 'testfederation.', 'testfederation-', 'testfederation_', + 'test.-_federation', 'Testfederation' + ] + + for value in valid_names: + bdict = apiutils.federation_post_data( + name=value, hostcluster_id=self.hostcluster.uuid) + bdict['uuid'] = uuidutils.generate_uuid() + response = self.post_json('/federations', bdict) + self.assertEqual(202, response.status_int) + + def test_create_federation_without_name(self): + bdict = apiutils.federation_post_data( + uuid=uuidutils.generate_uuid(), + hostcluster_id=self.hostcluster.uuid) + del bdict['name'] + response = self.post_json('/federations', bdict) + self.assertEqual(202, response.status_int) + + +class TestDelete(api_base.FunctionalTest): + def setUp(self): + super(TestDelete, self).setUp() + self.federation = obj_utils.create_test_federation( + self.context, name='federation-example', + uuid=uuidutils.generate_uuid()) + p = mock.patch.object(rpcapi.API, 'federation_delete_async') + self.mock_federation_delete = p.start() + self.mock_federation_delete.side_effect = \ + self._simulate_federation_delete + self.addCleanup(p.stop) + + def _simulate_federation_delete(self, federation_uuid): + federation = objects.Federation.get_by_uuid(self.context, + federation_uuid) + federation.destroy() + + def test_delete_federation(self): + self.delete('/federations/%s' % self.federation.uuid) + response = self.get_json('/federations/%s' % self.federation.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_federation_not_found(self): + delete = self.delete('/federations/%s' % uuidutils.generate_uuid(), + expect_errors=True) + self.assertEqual(404, delete.status_int) + self.assertEqual('application/json', delete.content_type) + self.assertTrue(delete.json['errors']) + + def test_delete_federation_with_name(self): + delete = self.delete('/federations/%s' % self.federation.name) + self.assertEqual(204, delete.status_int) + + def test_delete_federation_with_name_not_found(self): + delete = self.delete('/federations/%s' % 'foo', + expect_errors=True) + self.assertEqual(404, delete.status_int) + self.assertEqual('application/json', delete.content_type) + self.assertTrue(delete.json['errors']) diff --git a/magnum/tests/unit/api/utils.py b/magnum/tests/unit/api/utils.py index 2ad1293687..19cb42853c 100644 --- a/magnum/tests/unit/api/utils.py +++ b/magnum/tests/unit/api/utils.py @@ -20,6 +20,7 @@ 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.api.controllers.v1 import federation as federation_controller from magnum.tests.unit.db import utils @@ -86,3 +87,9 @@ def mservice_get_data(**kw): 'created_at': kw.get('created_at', faketime), 'updated_at': kw.get('updated_at', faketime), } + + +def federation_post_data(**kw): + federation = utils.get_test_federation(**kw) + internal = federation_controller.FederationPatchType.internal_attrs() + return remove_internal(federation, internal) diff --git a/magnum/tests/unit/conductor/handlers/test_federation_conductor.py b/magnum/tests/unit/conductor/handlers/test_federation_conductor.py new file mode 100644 index 0000000000..c48464b217 --- /dev/null +++ b/magnum/tests/unit/conductor/handlers/test_federation_conductor.py @@ -0,0 +1,38 @@ +# 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.conductor.handlers import federation_conductor +from magnum import objects +from magnum.tests.unit.db import base as db_base +from magnum.tests.unit.db import utils + + +class TestHandler(db_base.DbTestCase): + + def setUp(self): + super(TestHandler, self).setUp() + self.handler = federation_conductor.Handler() + federation_dict = utils.get_test_federation() + self.federation = objects.Federation(self.context, **federation_dict) + self.federation.create() + + def test_create_federation(self): + self.assertRaises(NotImplementedError, self.handler.federation_create, + self.context, self.federation, create_timeout=15) + + def test_update_federation(self): + self.assertRaises(NotImplementedError, self.handler.federation_update, + self.context, self.federation, rollback=False) + + def test_delete_federation(self): + self.assertRaises(NotImplementedError, self.handler.federation_delete, + self.context, self.federation.uuid) diff --git a/magnum/tests/unit/objects/test_federation.py b/magnum/tests/unit/objects/test_federation.py new file mode 100644 index 0000000000..6183786449 --- /dev/null +++ b/magnum/tests/unit/objects/test_federation.py @@ -0,0 +1,161 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock +from oslo_utils import uuidutils +from testtools.matchers import HasLength + +from magnum.common import exception +from magnum import objects +from magnum.tests.unit.db import base +from magnum.tests.unit.db import utils + + +class TestFederationObject(base.DbTestCase): + def setUp(self): + super(TestFederationObject, self).setUp() + self.fake_federation = utils.get_test_federation( + uuid=uuidutils.generate_uuid(), + hostcluster_id=uuidutils.generate_uuid(), + member_ids=[] + ) + + def test_get_by_id(self): + federation_id = self.fake_federation['id'] + with mock.patch.object(self.dbapi, 'get_federation_by_id', + autospec=True) as mock_get_federation: + mock_get_federation.return_value = self.fake_federation + federation = objects.Federation.get(self.context, federation_id) + mock_get_federation.assert_called_once_with(self.context, + federation_id) + self.assertEqual(self.context, federation._context) + + def test_get_by_uuid(self): + federation_uuid = self.fake_federation['uuid'] + with mock.patch.object(self.dbapi, 'get_federation_by_uuid', + autospec=True) as mock_get_federation: + mock_get_federation.return_value = self.fake_federation + federation = objects.Federation.get(self.context, federation_uuid) + mock_get_federation.assert_called_once_with(self.context, + federation_uuid) + self.assertEqual(self.context, federation._context) + + def test_get_by_name(self): + name = self.fake_federation['name'] + with mock.patch.object(self.dbapi, 'get_federation_by_name', + autospec=True) as mock_get_federation: + mock_get_federation.return_value = self.fake_federation + federation = objects.Federation.get_by_name(self.context, name) + mock_get_federation.assert_called_once_with(self.context, name) + self.assertEqual(self.context, federation._context) + + def test_get_bad_id_and_uuid(self): + self.assertRaises(exception.InvalidIdentity, + objects.Federation.get, self.context, 'not-a-uuid') + + def test_list(self): + with mock.patch.object(self.dbapi, 'get_federation_list', + autospec=True) as mock_get_list: + mock_get_list.return_value = [self.fake_federation] + federations = objects.Federation.list(self.context) + self.assertEqual(1, mock_get_list.call_count) + self.assertThat(federations, HasLength(1)) + self.assertIsInstance(federations[0], objects.Federation) + self.assertEqual(self.context, federations[0]._context) + + def test_list_all(self): + with mock.patch.object(self.dbapi, 'get_federation_list', + autospec=True) as mock_get_list: + mock_get_list.return_value = [self.fake_federation] + self.context.all_tenants = True + federations = objects.Federation.list(self.context) + mock_get_list.assert_called_once_with( + self.context, limit=None, marker=None, filters=None, + sort_dir=None, sort_key=None) + self.assertEqual(1, mock_get_list.call_count) + self.assertThat(federations, HasLength(1)) + self.assertIsInstance(federations[0], objects.Federation) + self.assertEqual(self.context, federations[0]._context) + + def test_list_with_filters(self): + with mock.patch.object(self.dbapi, 'get_federation_list', + autospec=True) as mock_get_list: + mock_get_list.return_value = [self.fake_federation] + filters = {'name': 'federation1'} + federations = objects.Federation.list(self.context, + filters=filters) + + mock_get_list.assert_called_once_with(self.context, sort_key=None, + sort_dir=None, + filters=filters, limit=None, + marker=None) + self.assertEqual(1, mock_get_list.call_count) + self.assertThat(federations, HasLength(1)) + self.assertIsInstance(federations[0], objects.Federation) + self.assertEqual(self.context, federations[0]._context) + + def test_create(self): + with mock.patch.object(self.dbapi, 'create_federation', + autospec=True) as mock_create_federation: + mock_create_federation.return_value = self.fake_federation + federation = objects.Federation(self.context, + **self.fake_federation) + federation.create() + mock_create_federation.assert_called_once_with( + self.fake_federation) + self.assertEqual(self.context, federation._context) + + def test_destroy(self): + uuid = self.fake_federation['uuid'] + with mock.patch.object(self.dbapi, 'get_federation_by_uuid', + autospec=True) as mock_get_federation: + mock_get_federation.return_value = self.fake_federation + with mock.patch.object(self.dbapi, 'destroy_federation', + autospec=True) as mock_destroy_federation: + federation = objects.Federation.get_by_uuid(self.context, uuid) + federation.destroy() + mock_get_federation.assert_called_once_with(self.context, uuid) + mock_destroy_federation.assert_called_once_with(uuid) + self.assertEqual(self.context, federation._context) + + def test_save(self): + uuid = self.fake_federation['uuid'] + with mock.patch.object(self.dbapi, 'get_federation_by_uuid', + autospec=True) as mock_get_federation: + mock_get_federation.return_value = self.fake_federation + with mock.patch.object(self.dbapi, 'update_federation', + autospec=True) as mock_update_federation: + federation = objects.Federation.get_by_uuid(self.context, uuid) + federation.member_ids = ['new-member'] + federation.save() + + mock_get_federation.assert_called_once_with(self.context, uuid) + mock_update_federation.assert_called_once_with( + uuid, {'member_ids': ['new-member']}) + self.assertEqual(self.context, federation._context) + + def test_refresh(self): + uuid = self.fake_federation['uuid'] + new_uuid = uuidutils.generate_uuid() + returns = [dict(self.fake_federation, uuid=uuid), + dict(self.fake_federation, uuid=new_uuid)] + expected = [mock.call(self.context, uuid), + mock.call(self.context, uuid)] + with mock.patch.object(self.dbapi, 'get_federation_by_uuid', + side_effect=returns, + autospec=True) as mock_get_federation: + federation = objects.Federation.get_by_uuid(self.context, uuid) + self.assertEqual(uuid, federation.uuid) + federation.refresh() + self.assertEqual(new_uuid, federation.uuid) + self.assertEqual(expected, mock_get_federation.call_args_list) + self.assertEqual(self.context, federation._context) diff --git a/magnum/tests/unit/objects/test_objects.py b/magnum/tests/unit/objects/test_objects.py index 504aafd056..03444a6674 100644 --- a/magnum/tests/unit/objects/test_objects.py +++ b/magnum/tests/unit/objects/test_objects.py @@ -363,6 +363,7 @@ object_data = { 'MagnumService': '1.0-2d397ec59b0046bd5ec35cd3e06efeca', 'Stats': '1.0-73a1cd6e3c0294c932a66547faba216c', 'Quota': '1.0-94e100aebfa88f7d8428e007f2049c18', + 'Federation': '1.0-166da281432b083f0e4b851336e12e20' } diff --git a/magnum/tests/unit/objects/utils.py b/magnum/tests/unit/objects/utils.py index d060bd6d0f..78b3e6fd26 100644 --- a/magnum/tests/unit/objects/utils.py +++ b/magnum/tests/unit/objects/utils.py @@ -157,6 +157,33 @@ def get_test_magnum_service_object(context, **kw): return magnum_service +def get_test_federation(context, **kw): + """Return a Federation object with appropriate attributes. + + NOTE: The object leaves the attributes marked as changed, such + that a create() could be used to commit it to the DB. + """ + db_federation = db_utils.get_test_federation(**kw) + # Let DB generate ID if it isn't specified explicitly + if 'id' not in kw: + del db_federation['id'] + federation = objects.Federation(context) + for key in db_federation: + setattr(federation, key, db_federation[key]) + return federation + + +def create_test_federation(context, **kw): + """Create and return a test Federation object. + + Create a Federation in the DB and return a Federation object with + appropriate attributes. + """ + federation = get_test_federation(context, **kw) + federation.create() + return federation + + def datetime_or_none(dt): """Validate a datetime or None value.""" if dt is None: diff --git a/releasenotes/notes/add-federation-api-cf55d04f96772b0f.yaml b/releasenotes/notes/add-federation-api-cf55d04f96772b0f.yaml new file mode 100644 index 0000000000..fb4f4273d3 --- /dev/null +++ b/releasenotes/notes/add-federation-api-cf55d04f96772b0f.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + This release introduces 'federations' endpoint + to Magnum API, which allows an admin to create + and manage federations of clusters through Magnum. + As the feature is still under development, + the endpoints are not bound to any driver yet. + For more details, please refer to bp/federation-api [1]. + + [1] https://review.openstack.org/#/q/topic:bp/federation-api