From 34754f36f015d59283995d72e118799181402942 Mon Sep 17 00:00:00 2001 From: Clenimar Filemon Date: Mon, 14 Aug 2017 14:46:10 +0200 Subject: [PATCH] federation api: federation table and db layer this commit introduces a new `Federation` table to Magnum database, as well as the necessary DB layer APIs to access and manage it. this belongs to the first phase of the implementation of the federation api. check [1] for more details. [1] https://review.openstack.org/#/c/489609/ Change-Id: Ie8a68cd3198c8fc7930069fd2e55f1cad55b6c9b Partially-Implements: blueprint federation-api --- magnum/common/exception.py | 8 + magnum/db/api.py | 96 +++++++ .../9a1539f1cd2c_add_federation_table.py | 47 ++++ magnum/db/sqlalchemy/api.py | 109 ++++++++ magnum/db/sqlalchemy/models.py | 18 ++ magnum/tests/unit/db/test_federation.py | 242 ++++++++++++++++++ magnum/tests/unit/db/utils.py | 30 +++ 7 files changed, 550 insertions(+) create mode 100644 magnum/db/sqlalchemy/alembic/versions/9a1539f1cd2c_add_federation_table.py create mode 100644 magnum/tests/unit/db/test_federation.py 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)