From 76b89e5fa5fcee43e694d583333e57e005e6ec50 Mon Sep 17 00:00:00 2001 From: Sebastian Krott Date: Mon, 3 Nov 2025 17:58:06 +0100 Subject: [PATCH] Add flavor permission rules Flavor access is currently controlled via private/public flavors and the flavor access list aka `FlavorProjects`. To restrict access to a flavor, it must be turned private and access must be granted individually to each project. To support external customers, we want to restrict access to certain flavors for arbitrary domains while keeping these flavors publicly accessible on other domain without additional configuration needs. Additionally, we want to enable project owners to configure project-specific flavor access. This adds flavor permission rules as a new mechanism for controlling flavor access. These rules are independent of flavor privacy and the flavor access list. So a flavor is only available to a project if allowed by both the flavor permission rules and the flavor access list. Each flavor permission rule has a `project_id`, an optional `flavor_id` and a `type` (`allow` or `deny`). If a project does not have any rules, then all flavors are permitted (i.e., default to the previous behavior). Rules without a `flavor_id` define the project's default behavior for flavors without a flavor-specific rule. If a project uses flavor permission rules, it is advisable to always define a corresponding default behavior rule without `flavor_id`. If a project does have rules but no default behavior rule, then flavors are denied by default. Depending on the specified `project_id`, flavor permission rules have two different scopes: - domain-project rules apply to all projects within the domain - rules of regular projects apply only to the project itself and are not inherited by sub-projects A flavor is permitted for a project if it is permitted at both the domain- and project-level. There is at most one flavor permission rule for each combination of `project_id` and `flavor_id`. Consequently, a flavor is permitted at the domain- or project-level if for that level: - No rules are defined - OR there is an `allow` rule matching the flavor - OR there is an `allow` rule without a `flavor_id` AND there is no `deny` rule matching the flavor Change-Id: I5e1332d111c07ee714f2965c558f393c8568ffaf --- ...177f53f978b_add_flavor_permission_rules.py | 55 ++++ nova/db/api/models.py | 27 ++ nova/exception.py | 15 + nova/objects/__init__.py | 1 + nova/objects/fields.py | 11 + nova/objects/flavor.py | 2 + nova/objects/flavor_permission_rule.py | 250 +++++++++++++++ nova/tests/unit/db/api/test_migrations.py | 28 ++ .../tests/unit/fake_flavor_permission_rule.py | 49 +++ .../objects/test_flavor_permission_rule.py | 301 ++++++++++++++++++ 10 files changed, 739 insertions(+) create mode 100644 nova/db/api/migrations/versions/f177f53f978b_add_flavor_permission_rules.py create mode 100644 nova/objects/flavor_permission_rule.py create mode 100644 nova/tests/unit/fake_flavor_permission_rule.py create mode 100644 nova/tests/unit/objects/test_flavor_permission_rule.py diff --git a/nova/db/api/migrations/versions/f177f53f978b_add_flavor_permission_rules.py b/nova/db/api/migrations/versions/f177f53f978b_add_flavor_permission_rules.py new file mode 100644 index 00000000000..c599881cc33 --- /dev/null +++ b/nova/db/api/migrations/versions/f177f53f978b_add_flavor_permission_rules.py @@ -0,0 +1,55 @@ +# 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_flavor_permission_rules + +Revision ID: f177f53f978b +Revises: cdeec0c85668 +Create Date: 2025-10-24 15:43:26.554412 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f177f53f978b' +down_revision = 'cdeec0c85668' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'flavor_permission_rules', + sa.Column('created_at', sa.DateTime), + sa.Column('updated_at', sa.DateTime), + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('uuid', sa.String(36), nullable=False), + sa.Column('project_id', sa.String(255), nullable=False), + sa.Column('flavor_id', sa.Integer, sa.ForeignKey('flavors.id'), + nullable=True), + sa.Column( + 'type', + sa.Enum('allow', 'deny', name='flavor_permission_rules0type'), + nullable=False + ), + sa.UniqueConstraint('uuid', name='uniq_flavor_permission_rules0uuid'), + sa.UniqueConstraint( + 'flavor_id', 'project_id', + name='uniq_flavor_permission_rules0flavor_id0project_id' + ), + sa.Index('flavor_permission_rules_uuid_idx', 'uuid'), + sa.Index('flavor_permission_rules_project_id_idx', 'project_id'), + mysql_engine='InnoDB', + mysql_charset='utf8', + ) diff --git a/nova/db/api/models.py b/nova/db/api/models.py index f3175a0b688..67c827cc6a4 100644 --- a/nova/db/api/models.py +++ b/nova/db/api/models.py @@ -273,6 +273,33 @@ class FlavorProjects(BASE): primaryjoin='FlavorProjects.flavor_id == Flavors.id') +class FlavorPermissionRule(BASE): + """Represents a flavor permission rule for a project""" + + __tablename__ = 'flavor_permission_rules' + __table_args__ = ( + schema.UniqueConstraint( + 'uuid', name='uniq_flavor_permission_rules0uuid'), + schema.UniqueConstraint( + 'flavor_id', + 'project_id', + name='uniq_flavor_permission_rules0flavor_id0project_id' + ), + sa.Index('flavor_permission_rules_uuid_idx', 'uuid'), + sa.Index('flavor_permission_rules_project_id_idx', 'project_id'), + ) + + id = sa.Column(sa.Integer, primary_key=True) + uuid = sa.Column(sa.String(36), nullable=False) + project_id = sa.Column(sa.String(255), nullable=False) + # Use NULL to represent all flavors + flavor_id = sa.Column( + sa.Integer, sa.ForeignKey('flavors.id'), nullable=True) + type = sa.Column( + sa.Enum('allow', 'deny', name='flavor_permission_rules0type'), + nullable=False) + + class BuildRequest(BASE): """Represents the information passed to the scheduler.""" diff --git a/nova/exception.py b/nova/exception.py index 262d665a0ff..08a4670adf7 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -1164,6 +1164,21 @@ class FlavorAccessExists(NovaException): "and project %(project_id)s combination.") +class FlavorPermissionRuleExists(NovaException): + msg_fmt = _("Flavor permission rule already exists for uuid %(uuid)s or " + "combination of project %(project_id)s and flavor " + "%(flavor_id)s.") + + +class FlavorPermissionRuleNotFound(NotFound): + msg_fmt = _("Flavor permission rule not found for id %(id)s") + + +class FlavorPermissionRuleNotFoundForProjectFlavor(NotFound): + msg_fmt = _("Flavor permission rule not found for project %(project_id)s " + "and flavor %(flavor_id)s combination.") + + class InvalidSharedStorage(NovaException): msg_fmt = _("%(path)s is not on shared storage: %(reason)s") diff --git a/nova/objects/__init__.py b/nova/objects/__init__.py index 5f7e4382518..87e4c1f5807 100644 --- a/nova/objects/__init__.py +++ b/nova/objects/__init__.py @@ -34,6 +34,7 @@ def register_all(): __import__('nova.objects.ec2') __import__('nova.objects.external_event') __import__('nova.objects.flavor') + __import__('nova.objects.flavor_permission_rule') __import__('nova.objects.host_mapping') __import__('nova.objects.hv_spec') __import__('nova.objects.image_meta') diff --git a/nova/objects/fields.py b/nova/objects/fields.py index 8f859e285cf..4744123f6b5 100644 --- a/nova/objects/fields.py +++ b/nova/objects/fields.py @@ -1052,6 +1052,13 @@ class InstanceTaskState(BaseNovaEnum): SHELVING_OFFLOADING, UNSHELVING, IN_CLUSTER_VMOTION) +class FlavorPermissionRuleType(BaseNovaEnum): + ALLOW = 'allow' + DENY = 'deny' + + ALL = (ALLOW, DENY) + + class InstancePowerState(Enum): _UNUSED = '_unused' NOSTATE = 'pending' @@ -1389,6 +1396,10 @@ class InstanceTaskStateField(BaseEnumField): AUTO_TYPE = InstanceTaskState() +class FlavorPermissionRuleTypeField(BaseEnumField): + AUTO_TYPE = FlavorPermissionRuleType() + + class InstancePowerStateField(BaseEnumField): AUTO_TYPE = InstancePowerState() diff --git a/nova/objects/flavor.py b/nova/objects/flavor.py index 240ff7df7fd..6737f6ba34b 100644 --- a/nova/objects/flavor.py +++ b/nova/objects/flavor.py @@ -191,6 +191,8 @@ def _flavor_destroy(context, flavor_id=None, flavorid=None): filter_by(flavor_id=result.id).delete() context.session.query(api_models.FlavorExtraSpecs).\ filter_by(flavor_id=result.id).delete() + context.session.query(api_models.FlavorPermissionRule).\ + filter_by(flavor_id=result.id).delete() context.session.delete(result) return result diff --git a/nova/objects/flavor_permission_rule.py b/nova/objects/flavor_permission_rule.py new file mode 100644 index 00000000000..ae43094ea9d --- /dev/null +++ b/nova/objects/flavor_permission_rule.py @@ -0,0 +1,250 @@ +# Copyright (c) 2025 SAP SE +# 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_db import exception as db_exc +from oslo_db.sqlalchemy.utils import paginate_query + +from nova.db.api import api as api_db_api +from nova.db.api import models as api_models +from nova.db import utils as db_utils +from nova import exception +from nova.objects import base +from nova.objects import fields + + +@base.NovaObjectRegistry.register +class FlavorPermissionRule(base.NovaPersistentObject, base.NovaObject): + """Restricts which flavors are permitted for which domains and projects. + + A flavor permission rule has a 'project_id', an optional 'flavor_id' and a + 'type' ('allow' or 'deny'). If a project does not have any rules, then all + flavors are permitted. Rules without a 'flavor_id' define the project's + default behavior for flavors without a flavor-specific rule. If a project + uses flavor permission rules, it is advisable to always define a + corresponding default behavior rule without 'flavor_id'. If a project does + have rules but no default behavior rule, then flavors are denied by + default. + + Depending on the specified 'project_id', flavor permission rules have two + different scopes: + - domain-project rules apply to all projects within the domain + - rules of regular projects apply only to the project itself and are not + inherited by sub-projects + + A flavor is permitted for a project if it is permitted at both the domain- + and project-level. + + There is at most one flavor permission rule for each combination of + 'project_id' and 'flavor_id'. Consequently, a flavor is permitted at the + domain- or project-level if for that level: + - No rules are defined + - OR there is an 'allow' rule matching the flavor + - OR there is an 'allow' rule without a 'flavor_id' AND there is no 'deny' + rule matching the flavor + + Note: Flavor permission rules are independent of flavor privacy and the + corresponding flavor access list. A flavor is only available to a project + if allowed by both the flavor permission rules and the flavor access list. + """ + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'id': fields.IntegerField(), + 'uuid': fields.UUIDField(), + 'project_id': fields.StringField(), + 'flavor_id': fields.IntegerField(nullable=True), + 'type': fields.FlavorPermissionRuleTypeField(), + } + + @staticmethod + def _from_db_object(context, rule, db_rule): + # NOTE(sebkro) Delete fields are not implemented in the API DB models, + # but are inherited from NovaPersistentObject + ignore = {'deleted': False, 'deleted_at': None} + for field in rule.fields: + if field in ignore and not hasattr(db_rule, field): + setattr(rule, field, ignore[field]) + if field in db_rule: + setattr(rule, field, db_rule[field]) + rule._context = context + rule.obj_reset_changes() + return rule + + @staticmethod + @db_utils.require_context + @api_db_api.context_manager.reader + def _get_by_id_from_db(context, id): + query = context.session.query( + api_models.FlavorPermissionRule).filter_by(id=id) + db_rule = query.first() + if not db_rule: + raise exception.FlavorPermissionRuleNotFound(id=id) + return db_rule + + @staticmethod + @db_utils.require_context + @api_db_api.context_manager.reader + def _get_by_uuid_from_db(context, uuid): + query = context.session.query( + api_models.FlavorPermissionRule).filter_by(uuid=uuid) + db_rule = query.first() + if not db_rule: + raise exception.FlavorPermissionRuleNotFound(id=uuid) + return db_rule + + @staticmethod + @db_utils.require_context + @api_db_api.context_manager.reader + def _get_by_project_and_flavor_from_db(context, project_id, flavor_id): + query = context.session.query( + api_models.FlavorPermissionRule).filter_by( + project_id=project_id).filter_by(flavor_id=flavor_id) + db_rule = query.first() + if not db_rule: + raise exception.FlavorPermissionRuleNotFoundForProjectFlavor( + project_id=project_id, flavor_id=flavor_id) + return db_rule + + @staticmethod + @db_utils.require_context + @api_db_api.context_manager.writer + def _create_in_db(context, values): + db_rule = api_models.FlavorPermissionRule() + db_rule.update(values) + try: + db_rule.save(context.session) + except db_exc.DBDuplicateEntry: + raise exception.FlavorPermissionRuleExists( + uuid=values.get('uuid'), + project_id=values.get('project_id'), + flavor_id=values.get('flavor_id')) + return db_rule + + @staticmethod + @db_utils.require_context + @api_db_api.context_manager.writer + def _destroy_in_db(context, id): + result = context.session.query( + api_models.FlavorPermissionRule).filter_by(id=id).delete() + if not result: + raise exception.FlavorPermissionRuleNotFound(id=id) + + @staticmethod + @db_utils.require_context + @api_db_api.context_manager.writer + def _save(context, id, values): + db_rule = FlavorPermissionRule._get_by_id_from_db(context, id) + db_rule.update(values) + try: + db_rule.save(context.session) + except db_exc.DBDuplicateEntry: + raise exception.FlavorPermissionRuleExists( + uuid=values.get('uuid'), + project_id=values.get('project_id'), + flavor_id=values.get('flavor_id')) + return db_rule + + @base.remotable_classmethod + def get_by_id(cls, context, id): + db_rule = cls._get_by_id_from_db(context, id) + return cls._from_db_object(context, cls(context), db_rule) + + @base.remotable_classmethod + def get_by_uuid(cls, context, uuid): + db_rule = cls._get_by_uuid_from_db(context, uuid) + return cls._from_db_object(context, cls(context), db_rule) + + @base.remotable_classmethod + def get_by_project_and_flavor(cls, context, project_id, flavor_id): + db_rule = cls._get_by_project_and_flavor_from_db( + context, project_id, flavor_id) + return cls._from_db_object(context, cls(context), db_rule) + + @base.remotable + def create(self): + updates = self.obj_get_changes() + db_rule = self._create_in_db(self._context, updates) + self._from_db_object(self._context, self, db_rule) + + @base.remotable + def destroy(self): + self._destroy_in_db(self._context, self.id) + + @base.remotable + def save(self): + updates = self.obj_get_changes() + if updates: + db_rule = self._save(self._context, self.id, updates) + # Refresh updated_at. + self._from_db_object(self._context, self, db_rule) + + +@base.NovaObjectRegistry.register +class FlavorPermissionRuleList(base.ObjectListBase, base.NovaObject): + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'objects': fields.ListOfObjectsField('FlavorPermissionRule'), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.objects = [] + self.obj_reset_changes() + + @staticmethod + @db_utils.require_context + @api_db_api.context_manager.reader + def _get_from_db(context, project_ids=None, limit=None, marker=None): + + # Return an empty list if project_ids is empty + if project_ids is not None and not project_ids: + return [] + + query = context.session.query(api_models.FlavorPermissionRule) + + if project_ids is not None: + query = query.filter( + api_models.FlavorPermissionRule.project_id.in_(project_ids)) + + marker_rule = None + if marker is not None: + marker_query = context.session.query( + api_models.FlavorPermissionRule).filter_by(uuid=marker) + marker_rule = marker_query.first() + if not marker_rule: + raise exception.MarkerNotFound(marker=marker) + + query = paginate_query( + query, api_models.FlavorPermissionRule, limit, ['id'], + marker=marker_rule) + return query.all() + + @base.remotable_classmethod + def get_by_projects(cls, context, project_ids, limit=None, marker=None): + db_rules = cls._get_from_db(context, project_ids=project_ids, + limit=limit, marker=marker) + return base.obj_make_list( + context, cls(context), FlavorPermissionRule, db_rules + ) + + @base.remotable_classmethod + def get_all(cls, context, limit=None, marker=None): + db_rules = cls._get_from_db(context, limit=limit, marker=marker) + return base.obj_make_list( + context, cls(context), FlavorPermissionRule, db_rules + ) diff --git a/nova/tests/unit/db/api/test_migrations.py b/nova/tests/unit/db/api/test_migrations.py index 37565208071..a7e6996d6e9 100644 --- a/nova/tests/unit/db/api/test_migrations.py +++ b/nova/tests/unit/db/api/test_migrations.py @@ -196,6 +196,34 @@ def _check_cdeec0c85668(self, connection): # removal without creating it first, which is dumb pass + def _pre_upgrade_f177f53f978b(self, connection): + # we use the inspector here rather than oslo_db.utils.column_exists, + # since the latter will create a new connection + inspector = sqlalchemy.inspect(connection) + self.assertFalse(inspector.has_table('flavor_permission_rules')) + + def _check_f177f53f978b(self, connection): + # we use the inspector here rather than oslo_db.utils.column_exists, + # since the latter will create a new connection + inspector = sqlalchemy.inspect(connection) + self.assertTrue(inspector.has_table('flavor_permission_rules')) + columns = {x['name'] for x in + inspector.get_columns('flavor_permission_rules')} + expected_columns = {'id', 'uuid', 'project_id', 'flavor_id', 'type'} + self.assertEqual(expected_columns, columns) + unique_constraints = [ + set(c['column_names']) for c in inspector.get_unique_constraints( + 'flavor_permission_rules')] + expected_unique_constraints = [{'uuid'}, {'flavor_id', 'project_id'}] + for c in expected_unique_constraints: + self.assertIn(c, unique_constraints) + indexes = [ + set(idx['column_names']) for idx in inspector.get_indexes( + 'flavor_permission_rules')] + expected_indexes = [{'uuid'}, {'project_id'}] + for idx in expected_indexes: + self.assertIn(idx, indexes) + def test_single_base_revision(self): """Ensure we only have a single base revision. diff --git a/nova/tests/unit/fake_flavor_permission_rule.py b/nova/tests/unit/fake_flavor_permission_rule.py new file mode 100644 index 00000000000..cf86a3ecf8a --- /dev/null +++ b/nova/tests/unit/fake_flavor_permission_rule.py @@ -0,0 +1,49 @@ +# Copyright (c) 2025 SAP SE +# 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_utils import uuidutils + +from nova import objects +from nova.objects import fields + + +def fake_db_flavor_permission_rule(**updates): + db_rule = { + 'id': 1, + 'uuid': uuidutils.generate_uuid(), + 'project_id': 'fake-project', + 'flavor_id': 123, + 'type': fields.FlavorPermissionRuleType.ALLOW, + } | updates + + for name, field in objects.FlavorPermissionRule.fields.items(): + if name in db_rule: + continue + if field.nullable: + db_rule[name] = None + elif field.default != fields.UnspecifiedDefault: + db_rule[name] = field.default + else: + raise Exception( + f'fake_db_flavor_permission_rule needs help with {name}') + + return db_rule + + +def fake_flavor_permission_rule_obj(context, db_rule=None, **updates): + if db_rule is None: + db_rule = fake_db_flavor_permission_rule() + return objects.FlavorPermissionRule._from_db_object( + context, objects.FlavorPermissionRule(), db_rule | updates) diff --git a/nova/tests/unit/objects/test_flavor_permission_rule.py b/nova/tests/unit/objects/test_flavor_permission_rule.py new file mode 100644 index 00000000000..eddc2cf1638 --- /dev/null +++ b/nova/tests/unit/objects/test_flavor_permission_rule.py @@ -0,0 +1,301 @@ +# Copyright (c) 2025 SAP SE +# 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 datetime import datetime +from unittest import mock + +from nova import context +from nova.db.api import api as api_db_api +from nova.db.api import models as api_models +from nova import exception +from nova import objects +from nova.objects import fields +from nova import test +from nova.tests.unit import fake_flavor_permission_rule as fake_rule +from nova.tests.unit.objects import test_objects + + +class _TestFlavorPermissionRuleObject: + """Mixin for FlavorPermissionRule object test cases.""" + + _fake_db_rule = fake_rule.fake_db_flavor_permission_rule() + + def _get_fake_db_rule(self, **updates): + return fake_rule.fake_db_flavor_permission_rule(**updates) + + def _get_fake_rule_obj(self, db_rule=None): + if db_rule is None: + db_rule = self._fake_db_rule + return fake_rule.fake_flavor_permission_rule_obj( + self.context, db_rule) + + def _compare_obj(self, rule_obj, db_rule, db_allow_missing=None, + db_allow_none=None): + if db_allow_none is None: + db_allow_none = [] + if db_allow_missing is None: + db_allow_missing = [] + for field in rule_obj.fields: + if field not in db_rule and field in db_allow_missing: + self.assertTrue(rule_obj.obj_attr_is_set(field)) + continue + + db_val = db_rule[field] + if db_val is None and field in db_allow_none: + self.assertTrue(rule_obj.obj_attr_is_set(field)) + continue + + obj_val = getattr(rule_obj, field) + if isinstance(obj_val, datetime): + obj_val = obj_val.replace(tzinfo=None) + # Indirection API loses microsecond precision + if rule_obj.indirection_api: + db_val = db_val.replace(microsecond=0) + + self.assertEqual(db_val, obj_val, f'field: {field}') + + @staticmethod + @api_db_api.context_manager.writer + def _create_context_db_rule(context, fake_db_rule=None): + if fake_db_rule is None: + fake_db_rule = _TestFlavorPermissionRuleObject._fake_db_rule + fake_db_rule = fake_db_rule.copy() + fake_db_rule.pop('id') + db_rule = api_models.FlavorPermissionRule() + db_rule.update(fake_db_rule) + db_rule.save(context.session) + return db_rule + + def _create_db_rule(self, fake_db_rule=None): + return self._create_context_db_rule(self.context, fake_db_rule) + + +class TestFlavorPermissionRuleObjectNoDB(test.NoDBTestCase, + _TestFlavorPermissionRuleObject): + + def setUp(self): + super().setUp() + # Set up context like _BaseTestCase does + self.user_id = 'fake-user' + self.project_id = 'fake-project' + self.context = context.RequestContext(self.user_id, self.project_id) + + @mock.patch('nova.objects.FlavorPermissionRule._get_by_id_from_db') + def test_get_by_id(self, mock_get): + mock_get.return_value = self._fake_db_rule + rule = objects.FlavorPermissionRule.get_by_id( + self.context, self._fake_db_rule['id']) + self._compare_obj(rule, self._fake_db_rule) + mock_get.assert_called_once_with( + self.context, self._fake_db_rule['id']) + + @mock.patch('nova.objects.FlavorPermissionRule._get_by_uuid_from_db') + def test_get_by_uuid(self, mock_get): + mock_get.return_value = self._fake_db_rule + rule = objects.FlavorPermissionRule.get_by_uuid( + self.context, self._fake_db_rule['uuid']) + self._compare_obj(rule, self._fake_db_rule) + mock_get.assert_called_once_with( + self.context, self._fake_db_rule['uuid']) + + @mock.patch('nova.objects.FlavorPermissionRule.' + '_get_by_project_and_flavor_from_db') + def test_get_by_project_and_flavor(self, mock_get): + mock_get.return_value = self._fake_db_rule + rule = objects.FlavorPermissionRule.get_by_project_and_flavor( + self.context, self._fake_db_rule['project_id'], + self._fake_db_rule['flavor_id']) + self._compare_obj(rule, self._fake_db_rule) + mock_get.assert_called_once_with( + self.context, self._fake_db_rule['project_id'], + self._fake_db_rule['flavor_id']) + + @mock.patch('nova.objects.FlavorPermissionRule._create_in_db') + def test_create(self, mock_create): + mock_create.return_value = self._fake_db_rule + fake_db_rule = self._fake_db_rule.copy() + fake_db_rule.pop('id') + # Create rule with tracked changes + rule = objects.FlavorPermissionRule(context=self.context, + **fake_db_rule) + rule.create() + mock_create.assert_called_once_with(self.context, fake_db_rule) + self._compare_obj(rule, self._fake_db_rule) + + @mock.patch('nova.objects.FlavorPermissionRule._destroy_in_db') + def test_destroy(self, mock_destroy): + mock_destroy.return_value = self._fake_db_rule + rule = self._get_fake_rule_obj() + rule.destroy() + mock_destroy.assert_called_once_with(self.context, 1) + + @mock.patch('nova.objects.FlavorPermissionRule._save') + def test_save(self, mock_save): + new_type = fields.FlavorPermissionRuleType.DENY + mock_save.return_value = self._fake_db_rule | {'type': new_type} + rule = self._get_fake_rule_obj() + rule.type = new_type + rule.save() + mock_save.assert_called_once_with( + self.context, rule.id, {'type': new_type}) + + @mock.patch('nova.objects.FlavorPermissionRule._save') + def test_save_no_changes(self, mock_save): + rule = self._get_fake_rule_obj() + rule.obj_reset_changes() + rule.save() + mock_save.assert_not_called() + + +class _TestFlavorPermissionRuleObjectDB(_TestFlavorPermissionRuleObject): + """Mixin for FlavorPermissionRule object test cases with DB.""" + + def test_get_by_id(self): + db_rule = self._create_db_rule() + rule = objects.FlavorPermissionRule.get_by_id(self.context, db_rule.id) + self._compare_obj(rule, db_rule) + + def test_get_by_uuid(self): + db_rule = self._create_db_rule() + rule = objects.FlavorPermissionRule.get_by_uuid( + self.context, db_rule.uuid) + self._compare_obj(rule, db_rule) + + def test_get_by_project_and_flavor(self): + db_rule = self._create_db_rule() + rule = objects.FlavorPermissionRule.get_by_project_and_flavor( + self.context, db_rule.project_id, db_rule.flavor_id) + self._compare_obj(rule, db_rule) + + def test_create(self): + fake_db_rule = self._fake_db_rule.copy() + fake_db_rule.pop('id') + # Create rule with tracked changes + rule = objects.FlavorPermissionRule(context=self.context, + **fake_db_rule) + rule.create() + self.assertIsNotNone(rule.id) + self.assertIsNotNone(rule.created_at) + self._compare_obj(rule, fake_db_rule, db_allow_missing=['id'], + db_allow_none=['created_at']) + updated_rule = objects.FlavorPermissionRule.get_by_id( + self.context, rule.id) + self._compare_obj(updated_rule, fake_db_rule, db_allow_missing=['id'], + db_allow_none=['created_at']) + + def test_destroy(self): + db_rule = self._create_db_rule() + rule = objects.FlavorPermissionRule.get_by_id(self.context, db_rule.id) + rule.destroy() + self.assertRaises( + exception.FlavorPermissionRuleNotFound, + objects.FlavorPermissionRule.get_by_id, + self.context, db_rule.id) + + def test_save(self): + db_rule = self._create_db_rule() + rule = objects.FlavorPermissionRule.get_by_id(self.context, db_rule.id) + new_type = fields.FlavorPermissionRuleType.DENY + rule.type = new_type + rule.save() + updated_rule = objects.FlavorPermissionRule.get_by_id( + self.context, db_rule.id) + self.assertEqual(new_type, updated_rule.type) + + +class TestFlavorPermissionRuleObject( + test_objects._LocalTest, _TestFlavorPermissionRuleObjectDB): + pass + + +class TestFlavorPermissionRuleObjectRemote( + test_objects._RemoteTest, _TestFlavorPermissionRuleObjectDB): + pass + + +class TestFlavorPermissionRuleListObjectNoDB(test.NoDBTestCase, + _TestFlavorPermissionRuleObject): + + def setUp(self): + super().setUp() + # Set up context like _BaseTestCase does + self.user_id = 'fake-user' + self.project_id = 'fake-project' + self.context = context.RequestContext(self.user_id, self.project_id) + + @mock.patch('nova.objects.FlavorPermissionRuleList._get_from_db') + def test_get_by_projects(self, mock_get): + db_rule1 = self._fake_db_rule + db_rule2 = self._fake_db_rule | {'project_id': 'project-2'} + project_ids = {db_rule1['project_id'], db_rule2['project_id']} + mock_get.return_value = [db_rule1, db_rule2] + rules = objects.FlavorPermissionRuleList.get_by_projects( + self.context, project_ids) + self._compare_obj(rules[0], db_rule1) + self._compare_obj(rules[1], db_rule2) + mock_get.assert_called_once_with( + self.context, project_ids=project_ids, limit=None, marker=None) + + @mock.patch('nova.objects.FlavorPermissionRuleList._get_from_db') + def test_get_all(self, mock_get): + db_rule1 = self._fake_db_rule + db_rule2 = self._fake_db_rule | {'project_id': 'project-2'} + mock_get.return_value = [db_rule1, db_rule2] + rules = objects.FlavorPermissionRuleList.get_all(self.context) + self._compare_obj(rules[0], db_rule1) + self._compare_obj(rules[1], db_rule2) + mock_get.assert_called_once_with(self.context, limit=None, marker=None) + + +class _TestFlavorPermissionRuleListObject(_TestFlavorPermissionRuleObject): + + def test_get_by_projects(self): + db_rule1 = self._create_db_rule() + db_rule2 = self._create_db_rule( + self._get_fake_db_rule(project_id='project-2')) + rules = objects.FlavorPermissionRuleList.get_by_projects( + self.context, {}) + self.assertEqual(0, len(rules)) + rules = objects.FlavorPermissionRuleList.get_by_projects( + self.context, {db_rule2.project_id}) + self.assertEqual(1, len(rules)) + self.assertEqual(db_rule2.id, rules[0].id) + rules = objects.FlavorPermissionRuleList.get_by_projects( + self.context, {db_rule1.project_id, db_rule2.project_id}) + self.assertEqual(2, len(rules)) + + def test_get_all(self): + db_rule1 = self._create_db_rule() + db_rule2 = self._create_db_rule( + self._get_fake_db_rule(project_id='project-2')) + rules = objects.FlavorPermissionRuleList.get_all(self.context) + self.assertEqual(2, len(rules)) + rules = objects.FlavorPermissionRuleList.get_all(self.context, limit=1) + self.assertEqual(1, len(rules)) + self.assertEqual(db_rule1.id, rules[0].id) + rules = objects.FlavorPermissionRuleList.get_all(self.context, limit=1, + marker=db_rule1.uuid) + self.assertEqual(1, len(rules)) + self.assertEqual(db_rule2.id, rules[0].id) + + +class TestFlavorPermissionRuleListObject( + test_objects._LocalTest, _TestFlavorPermissionRuleListObject): + pass + + +class TestRemoteFlavorPermissionRuleListObject( + test_objects._RemoteTest, _TestFlavorPermissionRuleListObject): + pass