diff --git a/nova/db/sqlalchemy/api_migrations/migrate_repo/versions/027_quotas.py b/nova/db/sqlalchemy/api_migrations/migrate_repo/versions/027_quotas.py new file mode 100644 index 000000000000..8c95143c022c --- /dev/null +++ b/nova/db/sqlalchemy/api_migrations/migrate_repo/versions/027_quotas.py @@ -0,0 +1,124 @@ +# 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. +"""API Database migrations for quotas""" + +from migrate import UniqueConstraint +from sqlalchemy import Column +from sqlalchemy import DateTime +from sqlalchemy import ForeignKey +from sqlalchemy import Index +from sqlalchemy import Integer +from sqlalchemy import MetaData +from sqlalchemy import String +from sqlalchemy import Table + + +def upgrade(migrate_engine): + meta = MetaData() + meta.bind = migrate_engine + + quota_classes = Table('quota_classes', meta, + Column('created_at', DateTime), + Column('updated_at', DateTime), + Column('id', Integer, primary_key=True, nullable=False), + Column('class_name', String(length=255)), + Column('resource', String(length=255)), + Column('hard_limit', Integer), + Index('quota_classes_class_name_idx', 'class_name'), + mysql_engine='InnoDB', + mysql_charset='utf8' + ) + + quota_classes.create(checkfirst=True) + + quota_usages = Table('quota_usages', meta, + Column('created_at', DateTime), + Column('updated_at', DateTime), + Column('id', Integer, primary_key=True, nullable=False), + Column('project_id', String(length=255)), + Column('resource', String(length=255), nullable=False), + Column('in_use', Integer, nullable=False), + Column('reserved', Integer, nullable=False), + Column('until_refresh', Integer), + Column('user_id', String(length=255)), + Index('quota_usages_project_id_idx', 'project_id'), + Index('quota_usages_user_id_idx', 'user_id'), + mysql_engine='InnoDB', + mysql_charset='utf8' + ) + + quota_usages.create(checkfirst=True) + + quotas = Table('quotas', meta, + Column('id', Integer, primary_key=True, nullable=False), + Column('created_at', DateTime), + Column('updated_at', DateTime), + Column('project_id', String(length=255)), + Column('resource', String(length=255), nullable=False), + Column('hard_limit', Integer), + UniqueConstraint('project_id', 'resource', + name='uniq_quotas0project_id0resource'), + mysql_engine='InnoDB', + mysql_charset='utf8' + ) + + quotas.create(checkfirst=True) + + uniq_name = "uniq_project_user_quotas0user_id0project_id0resource" + project_user_quotas = Table('project_user_quotas', meta, + Column('id', Integer, primary_key=True, + nullable=False), + Column('created_at', DateTime), + Column('updated_at', DateTime), + Column('user_id', + String(length=255), + nullable=False), + Column('project_id', + String(length=255), + nullable=False), + Column('resource', + String(length=255), + nullable=False), + Column('hard_limit', Integer, nullable=True), + UniqueConstraint('user_id', 'project_id', 'resource', + name=uniq_name), + Index('project_user_quotas_project_id_idx', + 'project_id'), + Index('project_user_quotas_user_id_idx', + 'user_id'), + mysql_engine='InnoDB', + mysql_charset='utf8', + ) + + project_user_quotas.create(checkfirst=True) + + reservations = Table('reservations', meta, + Column('created_at', DateTime), + Column('updated_at', DateTime), + Column('id', Integer, primary_key=True, nullable=False), + Column('uuid', String(length=36), nullable=False), + Column('usage_id', Integer, ForeignKey('quota_usages.id'), + nullable=False), + Column('project_id', String(length=255)), + Column('resource', String(length=255)), + Column('delta', Integer, nullable=False), + Column('expire', DateTime), + Column('user_id', String(length=255)), + Index('reservations_project_id_idx', 'project_id'), + Index('reservations_uuid_idx', 'uuid'), + Index('reservations_expire_idx', 'expire'), + Index('reservations_user_id_idx', 'user_id'), + mysql_engine='InnoDB', + mysql_charset='utf8' + ) + + reservations.create(checkfirst=True) diff --git a/nova/db/sqlalchemy/api_models.py b/nova/db/sqlalchemy/api_models.py index 9b1248397d2c..970e7b6a46ac 100644 --- a/nova/db/sqlalchemy/api_models.py +++ b/nova/db/sqlalchemy/api_models.py @@ -14,6 +14,7 @@ from oslo_db.sqlalchemy import models from sqlalchemy import Boolean from sqlalchemy import Column +from sqlalchemy import DateTime from sqlalchemy.dialects.mysql import MEDIUMTEXT from sqlalchemy import Enum from sqlalchemy.ext.declarative import declarative_base @@ -423,3 +424,121 @@ class InstanceGroup(API_BASE): @property def members(self): return [m.instance_uuid for m in self._members] + + +class Quota(API_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 + Null, then the resource is unlimited. + """ + + __tablename__ = 'quotas' + __table_args__ = ( + schema.UniqueConstraint("project_id", "resource", + name="uniq_quotas0project_id0resource" + ), + ) + id = Column(Integer, primary_key=True) + + project_id = Column(String(255)) + + resource = Column(String(255), nullable=False) + hard_limit = Column(Integer) + + +class ProjectUserQuota(API_BASE): + """Represents a single quota override for a user with in a project.""" + + __tablename__ = 'project_user_quotas' + uniq_name = "uniq_project_user_quotas0user_id0project_id0resource" + __table_args__ = ( + schema.UniqueConstraint("user_id", "project_id", "resource", + name=uniq_name), + Index('project_user_quotas_project_id_idx', + 'project_id'), + Index('project_user_quotas_user_id_idx', + 'user_id',) + ) + id = Column(Integer, primary_key=True, nullable=False) + + project_id = Column(String(255), nullable=False) + user_id = Column(String(255), nullable=False) + + resource = Column(String(255), nullable=False) + hard_limit = Column(Integer) + + +class QuotaClass(API_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 Null, then the resource is unlimited. + """ + + __tablename__ = 'quota_classes' + __table_args__ = ( + Index('quota_classes_class_name_idx', 'class_name'), + ) + id = Column(Integer, primary_key=True) + + class_name = Column(String(255)) + + resource = Column(String(255)) + hard_limit = Column(Integer) + + +class QuotaUsage(API_BASE): + """Represents the current usage for a given resource.""" + + __tablename__ = 'quota_usages' + __table_args__ = ( + Index('quota_usages_project_id_idx', 'project_id'), + Index('quota_usages_user_id_idx', 'user_id'), + ) + id = Column(Integer, primary_key=True) + + project_id = Column(String(255)) + user_id = Column(String(255)) + resource = Column(String(255), nullable=False) + + in_use = Column(Integer, nullable=False) + reserved = Column(Integer, nullable=False) + + @property + def total(self): + return self.in_use + self.reserved + + until_refresh = Column(Integer) + + +class Reservation(API_BASE): + """Represents a resource reservation for quotas.""" + + __tablename__ = 'reservations' + __table_args__ = ( + Index('reservations_project_id_idx', 'project_id'), + Index('reservations_uuid_idx', 'uuid'), + Index('reservations_expire_idx', 'expire'), + Index('reservations_user_id_idx', 'user_id'), + ) + id = Column(Integer, primary_key=True, nullable=False) + uuid = Column(String(36), nullable=False) + + usage_id = Column(Integer, ForeignKey('quota_usages.id'), nullable=False) + + project_id = Column(String(255)) + user_id = Column(String(255)) + resource = Column(String(255)) + + delta = Column(Integer, nullable=False) + expire = Column(DateTime) + + usage = orm.relationship( + "QuotaUsage", + foreign_keys=usage_id, + primaryjoin='Reservation.usage_id == QuotaUsage.id') diff --git a/nova/tests/functional/db/api/test_migrations.py b/nova/tests/functional/db/api/test_migrations.py index 86a6f29b2648..172e10461320 100644 --- a/nova/tests/functional/db/api/test_migrations.py +++ b/nova/tests/functional/db/api/test_migrations.py @@ -466,6 +466,93 @@ class NovaAPIMigrationsWalk(test_migrations.WalkVersionsMixin): self.assertColumnExists(engine, 'resource_classes', 'id') self.assertColumnExists(engine, 'resource_classes', 'name') + def _check_027(self, engine, data): + # quota_classes + for column in ['created_at', + 'updated_at', + 'id', + 'class_name', + 'resource', + 'hard_limit']: + self.assertColumnExists(engine, 'quota_classes', column) + + self.assertIndexExists(engine, 'quota_classes', + 'quota_classes_class_name_idx') + + # quota_usages + for column in ['created_at', + 'updated_at', + 'id', + 'project_id', + 'resource', + 'in_use', + 'reserved', + 'until_refresh', + 'user_id']: + self.assertColumnExists(engine, 'quota_usages', column) + + self.assertIndexExists(engine, 'quota_usages', + 'quota_usages_project_id_idx') + self.assertIndexExists(engine, 'quota_usages', + 'quota_usages_user_id_idx') + + # quotas + for column in ['created_at', + 'updated_at', + 'id', + 'project_id', + 'resource', + 'hard_limit']: + self.assertColumnExists(engine, 'quotas', column) + + self.assertUniqueConstraintExists(engine, 'quotas', + ['project_id', 'resource']) + + # project_user_quotas + for column in ['created_at', + 'updated_at', + 'id', + 'user_id', + 'project_id', + 'resource', + 'hard_limit']: + self.assertColumnExists(engine, 'project_user_quotas', column) + + self.assertUniqueConstraintExists(engine, 'project_user_quotas', + ['user_id', 'project_id', 'resource']) + self.assertIndexExists(engine, 'project_user_quotas', + 'project_user_quotas_project_id_idx') + self.assertIndexExists(engine, 'project_user_quotas', + 'project_user_quotas_user_id_idx') + + # reservations + for column in ['created_at', + 'updated_at', + 'id', + 'uuid', + 'usage_id', + 'project_id', + 'resource', + 'delta', + 'expire', + 'user_id']: + self.assertColumnExists(engine, 'reservations', column) + + self.assertIndexExists(engine, 'reservations', + 'reservations_project_id_idx') + self.assertIndexExists(engine, 'reservations', + 'reservations_uuid_idx') + self.assertIndexExists(engine, 'reservations', + 'reservations_expire_idx') + self.assertIndexExists(engine, 'reservations', + 'reservations_user_id_idx') + # Ensure the foreign key still exists + inspector = reflection.Inspector.from_engine(engine) + # There should only be one foreign key here + fk = inspector.get_foreign_keys('reservations')[0] + self.assertEqual('quota_usages', fk['referred_table']) + self.assertEqual(['id'], fk['referred_columns']) + class TestNovaAPIMigrationsWalkSQLite(NovaAPIMigrationsWalk, test_base.DbTestCase, diff --git a/nova/tests/functional/db/test_quota_model.py b/nova/tests/functional/db/test_quota_model.py new file mode 100644 index 000000000000..9ef09bd4da3a --- /dev/null +++ b/nova/tests/functional/db/test_quota_model.py @@ -0,0 +1,65 @@ +# 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 nova.db.sqlalchemy import api_models +from nova.db.sqlalchemy import models +from nova import test + + +class QuotaTablesCompareTestCase(test.NoDBTestCase): + def _get_column_list(self, model): + column_list = [m.key for m in model.__table__.columns] + return column_list + + def _check_column_list(self, + columns_new, + columns_old, + added=None, + removed=None): + for c in added or []: + columns_new.remove(c) + for c in removed or []: + columns_old.remove(c) + intersect = set(columns_new).intersection(set(columns_old)) + if intersect != set(columns_new) or intersect != set(columns_old): + return False + return True + + def _compare_models(self, m_a, m_b, + added=None, removed=None): + added = added or [] + removed = removed or ['deleted_at', 'deleted'] + c_a = self._get_column_list(m_a) + c_b = self._get_column_list(m_b) + self.assertTrue(self._check_column_list(c_a, c_b, + added=added, + removed=removed)) + + def test_tables_quota(self): + self._compare_models(api_models.Quota(), + models.Quota()) + + def test_tables_project_user_quota(self): + self._compare_models(api_models.ProjectUserQuota(), + models.ProjectUserQuota()) + + def test_tables_quota_class(self): + self._compare_models(api_models.QuotaClass(), + models.QuotaClass()) + + def test_tables_quota_usage(self): + self._compare_models(api_models.QuotaUsage(), + models.QuotaUsage()) + + def test_tables_reservation(self): + self._compare_models(api_models.Reservation(), + models.Reservation())