From 697412bdb81404347cecf6b467107f3759b53ed2 Mon Sep 17 00:00:00 2001 From: Kien Nguyen Date: Fri, 6 Apr 2018 17:16:12 +0700 Subject: [PATCH] Add Quota SQL database models & api Change-Id: Iebd2e536a167eac42b24822ad9fabfd8dde8ced2 Partial-Implements: blueprint quota-support --- zun/common/exception.py | 17 +++ zun/db/api.py | 69 +++++++++++ ...cb595db_create_quota_quota_class_tables.py | 59 +++++++++ zun/db/sqlalchemy/api.py | 116 ++++++++++++++++++ zun/db/sqlalchemy/models.py | 38 +++++- zun/tests/unit/db/test_quota_classes.py | 107 ++++++++++++++++ zun/tests/unit/db/test_quotas.py | 86 +++++++++++++ zun/tests/unit/db/utils.py | 18 +++ 8 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 zun/db/sqlalchemy/alembic/versions/2b045cb595db_create_quota_quota_class_tables.py create mode 100644 zun/tests/unit/db/test_quota_classes.py create mode 100644 zun/tests/unit/db/test_quotas.py diff --git a/zun/common/exception.py b/zun/common/exception.py index ba8962e99..06dcb982c 100644 --- a/zun/common/exception.py +++ b/zun/common/exception.py @@ -681,3 +681,20 @@ class ContainerActionEventNotFound(ZunException): class ServerNotUsable(ZunException): message = _("Zun server not usable") code = 404 + + +class QuotaNotFound(NotFound): + message = _("Quota could not be found.") + + +class ProjectQuotaNotFound(QuotaNotFound): + message = _("Quota for project %(project_id)s could not be found.") + + +class QuotaExists(ZunException): + message = _("Quota exists for project %(project_id)s, " + "resource %(resource)s.") + + +class QuotaClassNotFound(QuotaNotFound): + message = _("Quota class %(class_name)s could not be found.") diff --git a/zun/db/api.py b/zun/db/api.py index c1c5e1874..9339b44c0 100644 --- a/zun/db/api.py +++ b/zun/db/api.py @@ -890,3 +890,72 @@ def action_event_finish(context, values): def action_events_get(context, action_id): """Get the events by action id.""" return _get_dbdriver_instance().action_events_get(context, action_id) + + +@profiler.trace("db") +def quota_create(context, project_id, resource, limit): + """Create a quota for the given project and resource""" + return _get_dbdriver_instance().quota_create(context, project_id, + resource, limit) + + +@profiler.trace("db") +def quota_get(context, project_id, resource): + """Retrieve a quota or raise if it does not exist""" + return _get_dbdriver_instance().quota_get(context, project_id, + resource) + + +@profiler.trace("db") +def quota_get_all_by_project(context, project_id): + """Retrieve all quotas associated with a given project""" + return _get_dbdriver_instance().quota_get_all_by_project(context, + project_id) + + +@profiler.trace("db") +def quota_update(context, project_id, resource, limit): + """Update a quota or raise if it does not exist""" + return _get_dbdriver_instance().quota_update(context, project_id, + resource, limit) + + +@profiler.trace("db") +def quota_destroy(context, project_id, resource): + """Destroy resource quota associated with a given project""" + return _get_dbdriver_instance().quota_destroy(context, project_id, + resource) + + +@profiler.trace("db") +def quota_class_create(context, class_name, resource, limit): + """Create a quota class for the given name and resource""" + return _get_dbdriver_instance().quota_class_create(context, class_name, + resource, limit) + + +@profiler.trace("db") +def quota_class_get(context, class_name, resource): + """Retrieve a quota class or raise if it does not exist""" + return _get_dbdriver_instance().quota_class_get(context, class_name, + resource) + + +@profiler.trace("db") +def quota_class_get_default(context): + """Retrieve all default quota""" + return _get_dbdriver_instance().quota_class_get_default(context) + + +@profiler.trace("db") +def quota_class_get_all_by_name(context, class_name): + """Retrieve all quotas associated with a given quota class""" + return _get_dbdriver_instance().quota_class_get_all_by_name( + context, class_name) + + +@profiler.trace("db") +def quota_class_update(context, class_name, resource, limit): + """Update a quota class or raise if it does not exist""" + return _get_dbdriver_instance().quota_class_update(context, class_name, + resource, limit) diff --git a/zun/db/sqlalchemy/alembic/versions/2b045cb595db_create_quota_quota_class_tables.py b/zun/db/sqlalchemy/alembic/versions/2b045cb595db_create_quota_quota_class_tables.py new file mode 100644 index 000000000..4ad2a88a3 --- /dev/null +++ b/zun/db/sqlalchemy/alembic/versions/2b045cb595db_create_quota_quota_class_tables.py @@ -0,0 +1,59 @@ +# 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. + +"""Create quota & quota class tables + +Revision ID: 2b045cb595db +Revises: 238f94009eab +Create Date: 2018-04-09 13:33:52.522262 + +""" + +# revision identifiers, used by Alembic. +revision = '2b045cb595db' +down_revision = '238f94009eab' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + 'quotas', + sa.Column('created_at', sa.DateTime()), + sa.Column('updated_at', sa.DateTime()), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.String(length=255), index=True), + sa.Column('resource', sa.String(length=255), nullable=False), + sa.Column('hard_limit', sa.Integer()), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8', + mysql_engine='InnoDB' + ) + + op.create_table( + 'quota_classes', + sa.Column('created_at', sa.DateTime()), + sa.Column('updated_at', sa.DateTime()), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('class_name', sa.String(length=255), index=True), + sa.Column('resource', sa.String(length=255)), + sa.Column('hard_limit', sa.Integer()), + sa.Index('quota_classes_class_name_idx', 'class_name'), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8', + mysql_engine='InnoDB' + ) + # ### end Alembic commands ### diff --git a/zun/db/sqlalchemy/api.py b/zun/db/sqlalchemy/api.py index 4875b4eed..e818237be 100644 --- a/zun/db/sqlalchemy/api.py +++ b/zun/db/sqlalchemy/api.py @@ -40,6 +40,8 @@ CONF = zun.conf.CONF _FACADE = None +_DEFAULT_QUOTA_NAME = 'default' + def _create_facade_lazily(): global _FACADE @@ -1043,3 +1045,117 @@ class Connection(object): events = _paginate_query(models.ContainerActionEvent, sort_dir='desc', sort_key='created_at', query=query) return events + + def quota_create(self, context, project_id, resource, limit): + quota_ref = models.Quota() + quota_ref.project_id = project_id + quota_ref.resource = resource + quota_ref.hard_limit = limit + session = get_session() + try: + quota_ref.save(session=session) + except db_exc.DBDuplicateEntry: + raise exception.QuotaExists(project_id=project_id, + resource=resource) + return quota_ref + + def quota_get(self, context, project_id, resource): + session = get_session() + with session.begin(): + query = model_query(models.Quota, session=session).\ + filter_by(project_id=project_id).\ + filter_by(resource=resource) + result = query.first() + if not result: + raise exception.ProjectQuotaNotFound(project_id=project_id) + return result + + def quota_get_all_by_project(self, context, project_id): + session = get_session() + with session.begin(): + rows = model_query(models.Quota, session=session).\ + filter_by(project_id=project_id).\ + all() + result = {'project_id': project_id} + for row in rows: + result[row.resource] = row.hard_limit + + return result + + def quota_update(self, context, project_id, resource, limit): + session = get_session() + with session.begin(): + query = model_query(models.Quota, session=session).\ + filter_by(project_id=project_id).\ + filter_by(resource=resource) + + result = query.update({'hard_limit': limit}) + if not result: + raise exception.ProjectQuotaNotFound(project_id=project_id) + + def quota_destroy(self, context, project_id, resource): + session = get_session() + with session.begin(): + query = model_query(models.Quota, session=session).\ + filter_by(project_id=project_id).\ + filter_by(resource=resource) + query.delete() + + def quota_class_create(self, context, class_name, resource, limit): + quota_class_ref = models.QuotaClass() + quota_class_ref.class_name = class_name + quota_class_ref.resource = resource + quota_class_ref.hard_limit = limit + session = get_session() + with session.begin(): + quota_class_ref.save(session=session) + return quota_class_ref + + def quota_class_get(self, context, class_name, resource): + session = get_session() + with session.begin(): + result = model_query(models.QuotaClass, session=session).\ + filter_by(class_name=class_name).\ + filter_by(resource=resource).\ + first() + + if not result: + raise exception.QuotaClassNotFound(class_name=class_name) + return result + + def quota_class_get_default(self, context): + session = get_session() + with session.begin(): + rows = model_query(models.QuotaClass, session=session).\ + filter_by(class_name=_DEFAULT_QUOTA_NAME).\ + all() + + result = {'class_name': _DEFAULT_QUOTA_NAME} + for row in rows: + result[row.resource] = row.hard_limit + + return result + + def quota_class_get_all_by_name(self, context, class_name): + session = get_session() + with session.begin(): + rows = model_query(models.QuotaClass, session=session).\ + filter_by(class_name=class_name).\ + all() + + result = {'class_name': class_name} + for row in rows: + result[row.resource] = row.hard_limit + + return result + + def quota_class_update(self, context, class_name, resource, limit): + session = get_session() + with session.begin(): + result = model_query(models.QuotaClass, session=session).\ + filter_by(class_name=class_name).\ + filter_by(resource=resource).\ + update({'hard_limit': limit}) + + if not result: + raise exception.QuotaClassNotFound(class_name=class_name) diff --git a/zun/db/sqlalchemy/models.py b/zun/db/sqlalchemy/models.py index 51e5c87b6..d30adc267 100644 --- a/zun/db/sqlalchemy/models.py +++ b/zun/db/sqlalchemy/models.py @@ -201,7 +201,7 @@ class Image(Base): __table_args__ = ( schema.UniqueConstraint('repo', 'tag', name='uniq_image0repotag'), table_args() - ) + ) id = Column(Integer, primary_key=True) project_id = Column(String(255)) user_id = Column(String(255)) @@ -460,3 +460,39 @@ class ContainerActionEvent(Base): result = Column(String(255)) traceback = Column(Text) details = Column(Text) + + +class Quota(Base): + """Represents a single quota override for a project + + If there is no row for a given project id and resource, then the + default for the quota class is used. If there is no row for a + given quota class and resource, then the default for the deployment + is used. If the row is present but the hard limit is None then the + resource is unlimited. + """ + + __tablename__ = 'quotas' + __table_args__ = () + id = Column(Integer, primary_key=True, nullable=False) + project_id = Column(String(255), index=True) + resource = Column(String(255), nullable=False) + hard_limit = Column(Integer) + + +class QuotaClass(Base): + """Represents a single quota override for a quota class + + If there is no row for a given quota class and resource, then the + default for the deployment is used. If the row is present but the + hard limit is None, then the resource is unlimited. + """ + + __tablename__ = 'quota_classes' + __table_args__ = ( + Index('quota_classes_class_name_idx', 'class_name'), + ) + id = Column(Integer, primary_key=True, nullable=False) + class_name = Column(String(255), index=True) + resource = Column(String(255)) + hard_limit = Column(Integer) diff --git a/zun/tests/unit/db/test_quota_classes.py b/zun/tests/unit/db/test_quota_classes.py new file mode 100644 index 000000000..e000a62e5 --- /dev/null +++ b/zun/tests/unit/db/test_quota_classes.py @@ -0,0 +1,107 @@ +# 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 Quota via the DB API""" +from zun.common import context +import zun.conf +from zun.db import api as dbapi +from zun.db.sqlalchemy import api as sqlalchemy_dbapi +from zun.tests.unit.db import base +from zun.tests.unit.db import utils + +CONF = zun.conf.CONF + + +class DBQuotaClassesTestCase(base.DbTestCase): + + def setUp(self): + super(DBQuotaClassesTestCase, self).setUp() + self.ctx = context.get_admin_context() + self.class_name = 'default' + self.resource = 'containers' + self.limit = 100 + + def test_create_quota_class(self): + quota_class = utils.create_test_quota_class(context=self.ctx, + class_name=self.class_name, + resource=self.resource, + limit=self.limit) + self.assertEqual(quota_class.class_name, self.class_name) + self.assertEqual(quota_class.resource, self.resource) + self.assertEqual(quota_class.hard_limit, self.limit) + + def test_get_quota_class(self): + quota_class = utils.create_test_quota_class(context=self.ctx, + class_name=self.class_name, + resource=self.resource, + limit=self.limit) + res = dbapi.quota_class_get(context=self.ctx, + class_name=quota_class.class_name, + resource=quota_class.resource) + self.assertEqual(quota_class.class_name, res.class_name) + self.assertEqual(quota_class.resource, res.resource) + self.assertEqual(quota_class.hard_limit, res.hard_limit) + + def test_get_default_quota_class(self): + default_quota_class_resource_1 = utils.create_test_quota_class( + context=self.ctx, + class_name=sqlalchemy_dbapi._DEFAULT_QUOTA_NAME, + resource='resource_1', + limit=10) + + default_quota_class_resource_2 = utils.create_test_quota_class( + context=self.ctx, + class_name=sqlalchemy_dbapi._DEFAULT_QUOTA_NAME, + resource='resource_2', + limit=20) + + res = dbapi.quota_class_get_default(self.ctx) + self.assertEqual(res['class_name'], + sqlalchemy_dbapi._DEFAULT_QUOTA_NAME) + self.assertEqual(res[default_quota_class_resource_1.resource], + default_quota_class_resource_1.hard_limit) + self.assertEqual(res[default_quota_class_resource_2.resource], + default_quota_class_resource_2.hard_limit) + + def test_get_all_by_name_quota_class(self): + quota_class_resource_1 = utils.create_test_quota_class( + context=self.ctx, + class_name='class_1', + resource='resource_1', + limit=10) + + quota_class_resource_2 = utils.create_test_quota_class( + context=self.ctx, + class_name='class_1', + resource='resource_2', + limit=20) + + res = dbapi.quota_class_get_all_by_name(self.ctx, 'class_1') + self.assertEqual(res['class_name'], + 'class_1') + self.assertEqual(res[quota_class_resource_1.resource], + quota_class_resource_1.hard_limit) + self.assertEqual(res[quota_class_resource_2.resource], + quota_class_resource_2.hard_limit) + + def test_update_quota_class(self): + quota_class = utils.create_test_quota_class(context=self.ctx, + class_name=self.class_name, + resource=self.resource, + limit=self.limit) + dbapi.quota_class_update( + self.ctx, quota_class.class_name, + quota_class.resource, 200) + updated_quota_class = dbapi.quota_class_get( + self.ctx, quota_class.class_name, + quota_class.resource) + self.assertEqual(updated_quota_class.hard_limit, 200) diff --git a/zun/tests/unit/db/test_quotas.py b/zun/tests/unit/db/test_quotas.py new file mode 100644 index 000000000..a036c0ae5 --- /dev/null +++ b/zun/tests/unit/db/test_quotas.py @@ -0,0 +1,86 @@ +# 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 Quota via the DB API""" +from zun.common import context +from zun.common import exception +import zun.conf +from zun.db import api as dbapi +from zun.tests.unit.db import base +from zun.tests.unit.db import utils + +CONF = zun.conf.CONF + + +class DBQuotaTestCase(base.DbTestCase): + + def setUp(self): + super(DBQuotaTestCase, self).setUp() + self.ctx = context.get_admin_context() + self.project_id = 'fake_project_id' + self.resource = 'containers' + self.limit = 100 + + def test_create_quota(self): + quota = utils.create_test_quota(context=self.ctx, + project_id=self.project_id, + resource=self.resource, + limit=self.limit) + self.assertEqual(quota.project_id, self.project_id) + self.assertEqual(quota.resource, self.resource) + self.assertEqual(quota.hard_limit, self.limit) + + def test_get_quota(self): + quota = utils.create_test_quota(context=self.ctx, + project_id=self.project_id, + resource=self.resource, + limit=self.limit) + res = dbapi.quota_get(context=self.ctx, + project_id=quota.project_id, + resource=quota.resource) + self.assertEqual(quota.project_id, res.project_id) + self.assertEqual(quota.resource, res.resource) + self.assertEqual(quota.hard_limit, res.hard_limit) + + def test_get_all_project_quota(self): + quota_1 = utils.create_test_quota(context=self.ctx, + project_d=self.project_id, + resource='resource_1', + limit=10) + quota_2 = utils.create_test_quota(context=self.ctx, + project_id=self.project_id, + resource='resource_2', + limit=20) + quotas = dbapi.quota_get_all_by_project(self.ctx, self.project_id) + self.assertEqual(quotas['project_id'], self.project_id) + self.assertEqual(quotas[quota_1.resource], quota_1.hard_limit) + self.assertEqual(quotas[quota_2.resource], quota_2.hard_limit) + + def test_destroy_quota(self): + quota = utils.create_test_quota(context=self.ctx, + project_id=self.project_id, + resource=self.resource, + limit=self.limit) + dbapi.quota_destroy(self.ctx, quota.project_id, quota.resource) + self.assertRaises(exception.ProjectQuotaNotFound, dbapi.quota_get, + self.ctx, quota.project_id, quota.resource) + + def test_update_quota(self): + quota = utils.create_test_quota(context=self.ctx, + project_id=self.project_id, + resource=self.resource, + limit=self.limit) + dbapi.quota_update(self.ctx, quota.project_id, + quota.resource, 200) + updated_quota = dbapi.quota_get(self.ctx, quota.project_id, + quota.resource) + self.assertEqual(updated_quota.hard_limit, 200) diff --git a/zun/tests/unit/db/utils.py b/zun/tests/unit/db/utils.py index 6642f33e3..3d21ea116 100644 --- a/zun/tests/unit/db/utils.py +++ b/zun/tests/unit/db/utils.py @@ -495,3 +495,21 @@ def get_test_action_event(**kwargs): for k, v in event_values.items(): setattr(fake_event, k, v) return fake_event + + +def create_test_quota(**kwargs): + context = kwargs.get('context') + project_id = kwargs.get('project_id', 'fake_project_id') + resource = kwargs.get('resource', 'containers') + limit = kwargs.get('limit', 100) + dbapi = _get_dbapi() + return dbapi.quota_create(context, project_id, resource, limit) + + +def create_test_quota_class(**kwargs): + context = kwargs.get('context') + class_name = kwargs.get('class_name', 'default') + resource = kwargs.get('resource', 'containers') + limit = kwargs.get('limit', 100) + dbapi = _get_dbapi() + return dbapi.quota_class_create(context, class_name, resource, limit)