diff --git a/magnum/common/exception.py b/magnum/common/exception.py index 464f0ba582..b99c20a17d 100755 --- a/magnum/common/exception.py +++ b/magnum/common/exception.py @@ -374,3 +374,11 @@ class TrusteeOrTrustToClusterFailed(MagnumException): class CertificatesToClusterFailed(MagnumException): message = _("Failed to create certificates for Cluster: %(cluster_uuid)s") + + +class FederationNotFound(ResourceNotFound): + message = _("Federation %(federation)s could not be found.") + + +class FederationAlreadyExists(Conflict): + message = _("A federation with UUID %(uuid)s already exists.") diff --git a/magnum/db/api.py b/magnum/db/api.py index b2878de137..0fc014617f 100644 --- a/magnum/db/api.py +++ b/magnum/db/api.py @@ -444,3 +444,99 @@ class Connection(object): :returns: Quota record. """ + + @abc.abstractmethod + def get_federation_by_id(self, context, federation_id): + """Return a federation for a given federation id. + + :param context: The security context + :param federation_id: The id of a federation + :returns: A federation + """ + + @abc.abstractmethod + def get_federation_by_uuid(self, context, federation_uuid): + """Return a federation for a given federation uuid. + + :param context: The security context + :param federation_uuid: The uuid of a federation + :returns: A federation + """ + + @abc.abstractmethod + def get_federation_by_name(self, context, federation_name): + """Return a federation for a given federation name. + + :param context: The security context + :param federation_name: The name of a federation + :returns: A federation + """ + + @abc.abstractmethod + def get_federation_list(self, context, limit=None, marker=None, + sort_key=None, sort_dir=None, filters=None): + """Get matching federations. + + Return a list of the specified columns for all federations that + match the specified filters. + + :param context: The security context + :param filters: Filters to apply. Defaults to None. + + :param limit: Maximum number of federations to return. + :param marker: the last item of the previous page; we return the next + result set. + :param sort_key: Attribute by which results should be sorted. + :param sort_dir: direction in which results should be sorted. + (asc, desc) + :returns: A list of tuples of the specified columns. + """ + + @abc.abstractmethod + def create_federation(self, values): + """Create a new federation. + + :param values: A dict containing several items used to identify + and track the federation. + For example: + :: + + { + 'uuid': uuidutils.generate_uuid(), + 'name': 'example', + 'hostcluster_id': '91c8dd07-14a2-4fd8-b084-915fa53552fd', + 'properties': 'dns-zone:example.com.' + } + + :returns: A federation. + """ + + @abc.abstractmethod + def destroy_federation(self, federation_id): + """Destroy a federation. + + This action *will not* destroy the host cluster nor the member + clusters. + :param federation_id: The id or uuid of a federation. + """ + + @abc.abstractmethod + def update_federation(self, federation_id, values): + """Update properties of a federation. + + :param federation_id: The id or uuid of a federation. + :param values: A dict containing several items used to identify + and track the federation. + For example: + :: + + { + 'uuid': uuidutils.generate_uuid(), + 'name': 'example', + 'hostcluster_id': '91c8dd07-14a2-4fd8-b084-915fa53552fd', + 'properties': 'dns-zone:example.com.' + } + + :returns: A federation. + :raises: FederationNotFound + """ diff --git a/magnum/db/sqlalchemy/alembic/versions/9a1539f1cd2c_add_federation_table.py b/magnum/db/sqlalchemy/alembic/versions/9a1539f1cd2c_add_federation_table.py new file mode 100644 index 0000000000..4c20c3e3c4 --- /dev/null +++ b/magnum/db/sqlalchemy/alembic/versions/9a1539f1cd2c_add_federation_table.py @@ -0,0 +1,47 @@ +# 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. + +""""add federation table + +Revision ID: 9a1539f1cd2c +Revises: 041d9a0f1159 +Create Date: 2017-08-07 11:47:29.865166 + +""" + +# revision identifiers, used by Alembic. +revision = '9a1539f1cd2c' +down_revision = '041d9a0f1159' + +from alembic import op +import sqlalchemy as sa + +from magnum.db.sqlalchemy import models + + +def upgrade(): + op.create_table( + 'federation', + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.String(length=255), nullable=True), + sa.Column('uuid', sa.String(length=36), nullable=True), + sa.Column('name', sa.String(length=255), nullable=True), + sa.Column('hostcluster_id', sa.String(length=255), nullable=True), + sa.Column('member_ids', models.JSONEncodedList(), nullable=True), + sa.Column('status', sa.String(length=20), nullable=True), + sa.Column('status_reason', sa.Text(), nullable=True), + sa.Column('properties', models.JSONEncodedList(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('uuid', name='uniq_federation0uuid') + ) diff --git a/magnum/db/sqlalchemy/api.py b/magnum/db/sqlalchemy/api.py index 205a356202..1c4823a2bf 100644 --- a/magnum/db/sqlalchemy/api.py +++ b/magnum/db/sqlalchemy/api.py @@ -643,3 +643,112 @@ class Connection(api.Connection): msg = (_('project_id %(project_id)s resource %(resource)s.') % {'project_id': project_id, 'resource': resource}) raise exception.QuotaNotFound(msg=msg) + + def _add_federation_filters(self, query, filters): + if filters is None: + filters = {} + + possible_filters = ["name", "project_id", "hostcluster_id", + "member_ids", "properties"] + + # TODO(clenimar): implement 'member_ids' filter as a contains query, + # so we return all the federations that have the given clusters, + # instead of all the federations that *only* have the exact given + # clusters. + + filter_names = set(filters).intersection(possible_filters) + filter_dict = {filter_name: filters[filter_name] + for filter_name in filter_names} + + query = query.filter_by(**filter_dict) + + if 'status' in filters: + query = query.filter( + models.Federation.status.in_(filters['status'])) + + return query + + def get_federation_by_id(self, context, federation_id): + query = model_query(models.Federation) + query = self._add_tenant_filters(context, query) + query = query.filter_by(id=federation_id) + try: + return query.one() + except NoResultFound: + raise exception.FederationNotFound(federation=federation_id) + + def get_federation_by_uuid(self, context, federation_uuid): + query = model_query(models.Federation) + query = self._add_tenant_filters(context, query) + query = query.filter_by(uuid=federation_uuid) + try: + return query.one() + except NoResultFound: + raise exception.FederationNotFound(federation=federation_uuid) + + def get_federation_by_name(self, context, federation_name): + query = model_query(models.Federation) + query = self._add_tenant_filters(context, query) + query = query.filter_by(name=federation_name) + try: + return query.one() + except MultipleResultsFound: + raise exception.Conflict('Multiple federations exist with same ' + 'name. Please use the federation uuid ' + 'instead.') + except NoResultFound: + raise exception.FederationNotFound(federation=federation_name) + + def get_federation_list(self, context, limit=None, marker=None, + sort_key=None, sort_dir=None, filters=None): + query = model_query(models.Federation) + query = self._add_tenant_filters(context, query) + query = self._add_federation_filters(query, filters) + return _paginate_query(models.Federation, limit, marker, + sort_key, sort_dir, query) + + def create_federation(self, values): + if not values.get('uuid'): + values['uuid'] = uuidutils.generate_uuid() + + federation = models.Federation() + federation.update(values) + try: + federation.save() + except db_exc.DBDuplicateEntry: + raise exception.FederationAlreadyExists(uuid=values['uuid']) + return federation + + def destroy_federation(self, federation_id): + session = get_session() + with session.begin(): + query = model_query(models.Federation, session=session) + query = add_identity_filter(query, federation_id) + + try: + query.one() + except NoResultFound: + raise exception.FederationNotFound(federation=federation_id) + + query.delete() + + def update_federation(self, federation_id, values): + if 'uuid' in values: + msg = _("Cannot overwrite UUID for an existing Federation.") + raise exception.InvalidParameterValue(err=msg) + + return self._do_update_federation(federation_id, values) + + def _do_update_federation(self, federation_id, values): + session = get_session() + with session.begin(): + query = model_query(models.Federation, session=session) + query = add_identity_filter(query, federation_id) + try: + ref = query.with_lockmode('update').one() + except NoResultFound: + raise exception.FederationNotFound(federation=federation_id) + + ref.update(values) + + return ref diff --git a/magnum/db/sqlalchemy/models.py b/magnum/db/sqlalchemy/models.py index 1cb017fc00..086236902f 100644 --- a/magnum/db/sqlalchemy/models.py +++ b/magnum/db/sqlalchemy/models.py @@ -238,3 +238,21 @@ class Quota(Base): project_id = Column(String(255)) resource = Column(String(255)) hard_limit = Column(Integer()) + + +class Federation(Base): + """Represents a Federation.""" + __tablename__ = 'federation' + __table_args__ = ( + schema.UniqueConstraint("uuid", name="uniq_federation0uuid"), + table_args() + ) + id = Column(Integer, primary_key=True) + project_id = Column(String(255)) + uuid = Column(String(36)) + name = Column(String(255)) + hostcluster_id = Column(String(255)) + member_ids = Column(JSONEncodedList) + status = Column(String(20)) + status_reason = Column(Text) + properties = Column(JSONEncodedDict) diff --git a/magnum/tests/unit/db/test_federation.py b/magnum/tests/unit/db/test_federation.py new file mode 100644 index 0000000000..3eec6ddbcd --- /dev/null +++ b/magnum/tests/unit/db/test_federation.py @@ -0,0 +1,242 @@ +# 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. + +"""Tests for manipulating Federations via the DB API""" +from oslo_utils import uuidutils +import six + +from magnum.common import context +from magnum.common import exception +from magnum.tests.unit.db import base +from magnum.tests.unit.db import utils + + +class DbFederationTestCase(base.DbTestCase): + def test_create_federation(self): + utils.create_test_federation() + + def test_create_federation_already_exists(self): + utils.create_test_federation() + self.assertRaises(exception.FederationAlreadyExists, + utils.create_test_federation) + + def test_get_federation_by_id(self): + federation = utils.create_test_federation() + res = self.dbapi.get_federation_by_id(self.context, federation.id) + self.assertEqual(federation.id, res.id) + self.assertEqual(federation.uuid, res.uuid) + + def test_get_federation_by_name(self): + federation = utils.create_test_federation() + res = self.dbapi.get_federation_by_name(self.context, federation.name) + self.assertEqual(federation.name, res.name) + self.assertEqual(federation.uuid, res.uuid) + + def test_get_federation_by_uuid(self): + federation = utils.create_test_federation() + res = self.dbapi.get_federation_by_uuid(self.context, federation.uuid) + self.assertEqual(federation.id, res.id) + self.assertEqual(federation.uuid, res.uuid) + + def test_get_federation_that_does_not_exist(self): + self.assertRaises(exception.FederationNotFound, + self.dbapi.get_federation_by_id, + self.context, 999) + self.assertRaises(exception.FederationNotFound, + self.dbapi.get_federation_by_uuid, + self.context, + '12345678-9999-0000-aaaa-123456789012') + self.assertRaises(exception.FederationNotFound, + self.dbapi.get_federation_by_name, + self.context, 'not_found') + + def test_get_federation_by_name_multiple_federation(self): + utils.create_test_federation(id=1, name='federation-1', + uuid=uuidutils.generate_uuid()) + utils.create_test_federation(id=2, name='federation-1', + uuid=uuidutils.generate_uuid()) + self.assertRaises(exception.Conflict, + self.dbapi.get_federation_by_name, + self.context, 'federation-1') + + def test_get_federation_list(self): + uuids = [] + for _ in range(5): + federation = utils.create_test_federation( + uuid=uuidutils.generate_uuid()) + uuids.append(six.text_type(federation.uuid)) + + res = self.dbapi.get_federation_list(self.context, sort_key='uuid') + res_uuids = [r.uuid for r in res] + self.assertEqual(sorted(uuids), res_uuids) + + def test_get_federation_list_sorted(self): + uuids = [] + for _ in range(5): + federation = utils.create_test_federation( + uuid=uuidutils.generate_uuid()) + uuids.append(six.text_type(federation.uuid)) + + res = self.dbapi.get_federation_list(self.context, sort_key='uuid') + res_uuids = [r.uuid for r in res] + self.assertEqual(sorted(uuids), res_uuids) + + self.assertRaises(exception.InvalidParameterValue, + self.dbapi.get_federation_list, + self.context, + sort_key='foo') + + def test_get_federation_list_with_filters(self): + fed1 = utils.create_test_federation( + id=1, + uuid=uuidutils.generate_uuid(), + name='fed1', + project_id='proj1', + hostcluster_id='master1', + member_ids=['member1', 'member2'], + properties={'dns-zone': 'fed1.com.'}) + + fed2 = utils.create_test_federation( + id=2, + uuid=uuidutils.generate_uuid(), + name='fed', + project_id='proj2', + hostcluster_id='master2', + member_ids=['member3', 'member4'], + properties={"dns-zone": "fed2.com."}) + + # NOTE(clenimar): we are specifying a project_id to the test + # resources above, which means that our current context + # (self.context) will not be able to see these resources. + # Create an admin context in order to test the queries: + ctx = context.make_admin_context(all_tenants=True) + + # Filter by name: + res = self.dbapi.get_federation_list(ctx, filters={'name': 'fed1'}) + self.assertEqual([fed1.id], [r.id for r in res]) + + res = self.dbapi.get_federation_list(ctx, filters={'name': 'foo'}) + self.assertEqual([], [r.id for r in res]) + + # Filter by project_id + res = self.dbapi.get_federation_list(ctx, + filters={'project_id': 'proj1'}) + self.assertEqual([fed1.id], [r.id for r in res]) + + res = self.dbapi.get_federation_list(ctx, + filters={'project_id': 'foo'}) + self.assertEqual([], [r.id for r in res]) + + # Filter by hostcluster_id + res = self.dbapi.get_federation_list(ctx, filters={ + 'hostcluster_id': 'master1'}) + self.assertEqual([fed1.id], [r.id for r in res]) + + res = self.dbapi.get_federation_list(ctx, filters={ + 'hostcluster_id': 'master2'}) + self.assertEqual([fed2.id], [r.id for r in res]) + + res = self.dbapi.get_federation_list(ctx, + filters={'hostcluster_id': 'foo'}) + self.assertEqual([], [r.id for r in res]) + + # Filter by member_ids (please note that it is currently implemented + # as an exact match. So it will only return federations whose member + # clusters are exactly those passed as a filter) + res = self.dbapi.get_federation_list( + ctx, filters={'member_ids': ['member1', 'member2']}) + self.assertEqual([fed1.id], [r.id for r in res]) + + res = self.dbapi.get_federation_list( + ctx, filters={'member_ids': ['foo']}) + self.assertEqual([], [r.id for r in res]) + + # Filter by properties + res = self.dbapi.get_federation_list( + ctx, filters={ + 'properties': {'dns-zone': 'fed2.com.'} + }) + self.assertEqual([fed2.id], [r.id for r in res]) + + res = self.dbapi.get_federation_list( + ctx, filters={ + 'properties': {'dns-zone': 'foo.bar.'} + }) + self.assertEqual([], [r.id for r in res]) + + def test_get_federation_list_by_admin_all_tenants(self): + uuids = [] + for _ in range(5): + federation = utils.create_test_federation( + uuid=uuidutils.generate_uuid(), + project_id=uuidutils.generate_uuid()) + uuids.append(six.text_type(federation['uuid'])) + + ctx = context.make_admin_context(all_tenants=True) + res = self.dbapi.get_federation_list(ctx) + res_uuids = [r.uuid for r in res] + self.assertEqual(len(res), 5) + self.assertEqual(sorted(uuids), sorted(res_uuids)) + + def test_destroy_federation(self): + federation = utils.create_test_federation() + self.assertIsNotNone( + self.dbapi.get_federation_by_id(self.context, federation.id)) + self.dbapi.destroy_federation(federation.id) + self.assertRaises(exception.FederationNotFound, + self.dbapi.get_federation_by_id, + self.context, federation.id) + + def test_destroy_federation_by_uuid(self): + federation = utils.create_test_federation( + uuid=uuidutils.generate_uuid()) + self.assertIsNotNone( + self.dbapi.get_federation_by_uuid(self.context, federation.uuid)) + self.dbapi.destroy_federation(federation.uuid) + self.assertRaises(exception.FederationNotFound, + self.dbapi.get_federation_by_uuid, + self.context, federation.uuid) + + def test_destroy_federation_by_id_that_does_not_exist(self): + self.assertRaises(exception.FederationNotFound, + self.dbapi.destroy_federation, + '12345678-9999-0000-aaaa-123456789012') + + def test_destroy_federation_by_uudid_that_does_not_exist(self): + self.assertRaises(exception.FederationNotFound, + self.dbapi.destroy_federation, '15') + + def test_update_federation_members(self): + federation = utils.create_test_federation() + old_members = federation.member_ids + new_members = old_members + ['new-member-id'] + self.assertNotEqual(old_members, new_members) + res = self.dbapi.update_federation(federation.id, + {'member_ids': new_members}) + self.assertEqual(new_members, res.member_ids) + + def test_update_federation_properties(self): + federation = utils.create_test_federation() + old_properties = federation.properties + new_properties = { + 'dns-zone': 'new.domain.com.' + } + self.assertNotEqual(old_properties, new_properties) + res = self.dbapi.update_federation(federation.id, + {'properties': new_properties}) + self.assertEqual(new_properties, res.properties) + + def test_update_federation_not_found(self): + federation_uuid = uuidutils.generate_uuid() + self.assertRaises(exception.FederationNotFound, + self.dbapi.update_federation, federation_uuid, + {'member_ids': ['foo']}) diff --git a/magnum/tests/unit/db/utils.py b/magnum/tests/unit/db/utils.py index bac51b147c..5b8ec7ddc3 100644 --- a/magnum/tests/unit/db/utils.py +++ b/magnum/tests/unit/db/utils.py @@ -236,3 +236,33 @@ def create_test_quotas(**kw): del quotas['id'] dbapi = db_api.get_instance() return dbapi.create_quota(quotas) + + +def get_test_federation(**kw): + return { + 'id': kw.get('id', 42), + 'uuid': kw.get('uuid', '60d6dbdc-9951-4cee-b020-55d3e15a749b'), + 'name': kw.get('name', 'fake-name'), + 'project_id': kw.get('project_id', 'fake_project'), + 'hostcluster_id': kw.get('hostcluster_id', 'fake_master'), + 'member_ids': kw.get('member_ids', ['fake_member1', 'fake_member2']), + 'properties': kw.get('properties', {'dns-zone': 'example.com.'}), + 'status': kw.get('status', 'CREATE_IN_PROGRESS'), + 'status_reason': kw.get('status_reason', 'Completed successfully.'), + 'created_at': kw.get('created_at'), + 'updated_at': kw.get('updated_at') + } + + +def create_test_federation(**kw): + """Create test federation entry in DB and return federation DB object. + + :param kw: kwargs with overriding values for federation attributes. + :return: Test quotas DB object. + """ + federation = get_test_federation(**kw) + # Let DB generate ID if it isn't specified explicitly + if 'id' not in kw: + del federation['id'] + dbapi = db_api.get_instance() + return dbapi.create_federation(federation)