diff --git a/ironic/common/exception.py b/ironic/common/exception.py index fdf40fe148..e39439dd67 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -1085,3 +1085,13 @@ class ImageServiceAuthenticationRequired(ImageUnacceptable): _msg_fmt = _("The requested image %(image_ref)s requires " "authentication which has not been provided. " "Unable to proceed.") + + +class InspectionRuleAlreadyExists(Conflict): + """Rule requested already exists in the database.""" + _msg_fmt = _("A rule with UUID %(uuid)s already exists.") + + +class InspectionRuleNotFound(NotFound): + """The requested rule was not found.""" + _msg_fmt = _("Rule %(rule)s could not be found.") diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index aada98e899..7b11629d82 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -796,6 +796,7 @@ RELEASE_MAPPING = { 'VolumeTarget': ['1.0'], 'FirmwareComponent': ['1.0'], 'Runbook': ['1.0'], + 'InspectionRule': ['1.0'], } }, } diff --git a/ironic/db/api.py b/ironic/db/api.py index 398321f9bb..55bd3f349a 100644 --- a/ironic/db/api.py +++ b/ironic/db/api.py @@ -1637,3 +1637,63 @@ class Connection(object, metaclass=abc.ABCMeta): should include child nodes with their own power supplies. :returns: A list of tuples. """ + + @abc.abstractmethod + def create_inspection_rule(self, values): + """Create an inspection rule. + + :param values: A dict describing the rule. + :raises: InspectionRuleAlreadyExists if an inspection rule with the + same UUID exists. + :returns: A rule. + """ + + @abc.abstractmethod + def update_inspection_rule(self, inspection_rule_id, values): + """Update an inspection rule. + + :param inspection_rule_id: The id or uuid of an inspection rule. + :param values: Dict of values to update. + :raises: InspectionRuleNotFound if the rule does not exist. + :returns: A rule. + """ + + @abc.abstractmethod + def get_inspection_rule_by_uuid(self, inspection_rule_uuid): + """Retrieve an inspection rule by UUID. + + :param inspection_rule_uuid: UUID of the rule to retrieve. + :raises: InspectionRuleNotFound if the rule does not exist. + :returns: A rule. + """ + + @abc.abstractmethod + def get_inspection_rule_by_id(self, inspection_rule_id): + """Retrieve an inspection rule by id. + + :param inspection_rule_id: id of the rule to retrieve. + :raises: InspectionRuleNotFound if the rule does not exist. + :returns: A rule. + """ + + @abc.abstractmethod + def get_inspection_rule_list(self, limit=None, marker=None, filters=None, + sort_key=None, sort_dir=None): + """Retrieve a list of inspection rules. + + :param limit: Maximum number of rules 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 inspection rules. + """ + + @abc.abstractmethod + def destroy_inspection_rule(self, inspection_rule_id): + """Destroy an inspection rule. + + :param inspection_rule_id: ID of the inspection_rule to destroy. + :raises: inspection_ruleNotFound if the inspection_rule does not exist. + """ diff --git a/ironic/db/sqlalchemy/alembic/versions/21c48150dea9_add_inspection_rules.py b/ironic/db/sqlalchemy/alembic/versions/21c48150dea9_add_inspection_rules.py new file mode 100644 index 0000000000..2860401727 --- /dev/null +++ b/ironic/db/sqlalchemy/alembic/versions/21c48150dea9_add_inspection_rules.py @@ -0,0 +1,54 @@ +# 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 inspection rules + +Revision ID: 21c48150dea9 +Revises: 66bd9c5604d5 +Create Date: 2024-08-14 14:13:24.462303 + +""" + +from alembic import op +from oslo_db.sqlalchemy import types +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '21c48150dea9' +down_revision = '6e9cf6acce0b' + + +def upgrade(): + op.create_table( + 'inspection_rules', + sa.Column('version', sa.String(length=15), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False, + autoincrement=True), + sa.Column('uuid', sa.String(36), nullable=False), + sa.Column('priority', sa.Integer(), nullable=False, default=0), + sa.Column('description', sa.String(255), nullable=True), + sa.Column('scope', sa.String(255), nullable=True), + sa.Column('sensitive', sa.Boolean(), default=False), + sa.Column('phase', sa.String(16), nullable=True), + sa.Column('conditions', types.JsonEncodedList(mysql_as_long=True), + nullable=True), + sa.Column('actions', types.JsonEncodedList(mysql_as_long=True), + nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('uuid', name='uniq_inspection_rules0uuid'), + sa.Index('inspection_rule_scope_idx', 'scope'), + sa.Index('inspection_rule_phase_idx', 'phase'), + mysql_ENGINE='InnoDB', + mysql_DEFAULT_CHARSET='UTF8' + ) diff --git a/ironic/db/sqlalchemy/api.py b/ironic/db/sqlalchemy/api.py index 09204d6809..be0af10fce 100644 --- a/ironic/db/sqlalchemy/api.py +++ b/ironic/db/sqlalchemy/api.py @@ -3171,3 +3171,76 @@ class Connection(api.Connection): continue nodes.append(r[0]) return nodes + + def create_inspection_rule(self, values): + """Create new rule""" + inspection_rule = models.InspectionRule() + inspection_rule.update(values) + + with _session_for_write() as session: + try: + session.add(inspection_rule) + session.flush() + except db_exc.DBDuplicateEntry: + raise exception.InspectionRuleAlreadyExists( + uuid=values['uuid']) + return inspection_rule + + def update_inspection_rule(self, rule_uuid, values): + """Update an existing inspection rule. + + :param values: Dict of values to update with. + :param rule_id: The rule id. + """ + with _session_for_write() as session: + query = session.query(models.InspectionRule).filter_by( + uuid=rule_uuid) + try: + ref = query.with_for_update().one() + except NoResultFound: + raise exception.InspectionRuleNotFound( + rule=rule_uuid) + ref.update(values) + return ref + + def _get_inspection_rule(self, field, value): + """Helper method for retrieving an inspection rule.""" + query = sa.select(models.InspectionRule).where(field == value) + try: + with _session_for_read() as session: + res = session.execute(query).one()[0] + return res + except NoResultFound: + raise exception.InspectionRuleNotFound(rule=value) + + def get_inspection_rule_by_uuid(self, inspection_rule_uuid): + return self._get_inspection_rule(models.InspectionRule.uuid, + inspection_rule_uuid) + + def get_inspection_rule_by_id(self, inspection_rule_id): + return self._get_inspection_rule(models.InspectionRule.id, + inspection_rule_id) + + def get_inspection_rule_list(self, limit=None, marker=None, filters=None, + sort_key=None, sort_dir=None): + query = (sa.select(models.InspectionRule)) + if filters is None: + filters = dict() + supported_filters = {'phase', 'scope'} + unsupported_filters = set(filters).difference(supported_filters) + if unsupported_filters: + msg = _("SqlAlchemy API does not support " + "filtering by %s") % ', '.join(unsupported_filters) + raise ValueError(msg) + for field in filters: + query = query.filter_by(**{field: filters[field]}) + return _paginate_query(models.InspectionRule, limit, marker, + sort_key, sort_dir, query) + + def destroy_inspection_rule(self, inspection_rule_id): + with _session_for_write() as session: + count = session.query(models.InspectionRule).filter_by( + id=inspection_rule_id).delete() + if count == 0: + raise exception.InspectionRuleNotFound( + rule=inspection_rule_id) diff --git a/ironic/db/sqlalchemy/models.py b/ironic/db/sqlalchemy/models.py index 800059afad..155f8fbb63 100644 --- a/ironic/db/sqlalchemy/models.py +++ b/ironic/db/sqlalchemy/models.py @@ -563,6 +563,26 @@ class RunbookStep(Base): ) +class InspectionRule(Base): + __tablename__ = 'inspection_rules' + __table_args__ = ( + schema.UniqueConstraint('uuid', name='uniq_inspection_rules0uuid'), + Index('inspection_rule_scope_idx', 'scope'), + Index('inspection_rule_phase_idx', 'phase'), + table_args()) + id = Column(Integer, primary_key=True) + uuid = Column(String(36), nullable=False) + priority = Column(Integer, nullable=False, default=0) + description = Column(String(255), nullable=True) + scope = Column(String(255), nullable=True) + sensitive = Column(Boolean, default=False) + phase = Column(String(16), nullable=True, default='main') + conditions = Column(db_types.JsonEncodedList(mysql_as_long=True), + nullable=True) + actions = Column(db_types.JsonEncodedList(mysql_as_long=True), + nullable=False) + + def get_class(model_name): """Returns the model class with the specified name. diff --git a/ironic/objects/__init__.py b/ironic/objects/__init__.py index 4d3ac2de84..4d790b34a6 100644 --- a/ironic/objects/__init__.py +++ b/ironic/objects/__init__.py @@ -31,6 +31,7 @@ def register_all(): __import__('ironic.objects.deploy_template') __import__('ironic.objects.deployment') __import__('ironic.objects.firmware') + __import__('ironic.objects.inspection_rule') __import__('ironic.objects.node') __import__('ironic.objects.node_history') __import__('ironic.objects.node_inventory') diff --git a/ironic/objects/inspection_rule.py b/ironic/objects/inspection_rule.py new file mode 100644 index 0000000000..1773f0d5f2 --- /dev/null +++ b/ironic/objects/inspection_rule.py @@ -0,0 +1,211 @@ +# 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_versionedobjects import base as object_base + +from ironic.db import api as db_api +from ironic.objects import base +from ironic.objects import fields as object_fields +from ironic.objects import notification + + +@base.IronicObjectRegistry.register +class InspectionRule(base.IronicObject, object_base.VersionedObjectDictCompat): + # Version 1.0: Initial version + VERSION = '1.0' + + dbapi = db_api.get_instance() + + fields = { + 'id': object_fields.IntegerField(), + 'uuid': object_fields.UUIDField(nullable=False), + 'priority': object_fields.IntegerField(default=0), + 'description': object_fields.StringField(nullable=True), + 'sensitive': object_fields.BooleanField(default=False), + 'phase': object_fields.StringField(nullable=True, default='main'), + 'scope': object_fields.StringField(nullable=True), + 'actions': object_fields.ListOfFlexibleDictsField(nullable=False), + 'conditions': object_fields.ListOfFlexibleDictsField(nullable=True), + } + + # NOTE(mgoddard): We don't want to enable RPC on this call just yet. + # Remotable methods can be used in the future to replace current explicit + # RPC calls. Implications of calling new remote procedures should be + # thought through. + # @object_base.remotable + def create(self, context=None): + """Create a InspectionRule record in the DB. + + :param context: security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: InspectionRule(context). + :raises: InspectionRuleName if a inspection rule with the same + name exists. + :raises: InspectionRuleAlreadyExists if a inspection rule with the same + UUID exists. + """ + values = self.do_version_changes_for_db() + db_rule = self.dbapi.create_inspection_rule(values) + self._from_db_object(self._context, self, db_rule) + + def save(self, context=None): + """Save updates to this InspectionRule. + + Column-wise updates will be made based on the result of + self.what_changed(). + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api, + but, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: InspectionRule(context) + :raises: InspectionRuleNotFound if the inspection rule does not exist. + """ + updates = self.do_version_changes_for_db() + db_rule = self.dbapi.update_inspection_rule(self.uuid, updates) + self._from_db_object(self._context, self, db_rule) + + def destroy(self): + """Delete the InspectionRule from the DB. + + :param context: security context. NOTE: This should only + be used internally by the indirection_api, + but, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: InspectionRule(context). + :raises: InspectionRuleNotFound if the inspection_rule no longer + appears in the database. + """ + self.dbapi.destroy_inspection_rule(self.id) + self.obj_reset_changes() + + # NOTE(mgoddard): We don't want to enable RPC on this call just yet. + # Remotable methods can be used in the future to replace current explicit + # RPC calls. Implications of calling new remote procedures should be + # thought through. + # @object_base.remotable_classmethod + @classmethod + def get_by_uuid(cls, context, uuid): + """Find a inspection rule based on its UUID. + + :param context: security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: InspectionRule(context). + :param uuid: The UUID of a inspection rule. + :raises: InspectionRuleNotFound if the inspection rule no longer + appears in the database. + :returns: a :class:`InspectionRule` object. + """ + db_rule = cls.dbapi.get_inspection_rule_by_uuid(uuid) + rule = cls._from_db_object(context, cls(), db_rule) + return rule + + # NOTE(mgoddard): We don't want to enable RPC on this call just yet. + # Remotable methods can be used in the future to replace current explicit + # RPC calls. Implications of calling new remote procedures should be + # thought through. + # @object_base.remotable_classmethod + @classmethod + def list(cls, context, limit=None, marker=None, sort_key=None, + sort_dir=None, filters=None): + """Return a list of InspectionRule objects. + + :param context: security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: InspectionRule(context). + :param limit: maximum number of resources to return in a single result. + :param marker: pagination marker for large data sets. + :param sort_key: column to sort results by. + :param sort_dir: direction to sort. "asc" or "desc". + :returns: a list of :class:`InspectionRule` objects. + """ + db_rules = cls.dbapi.get_inspection_rule_list( + limit=limit, marker=marker, sort_key=sort_key, sort_dir=sort_dir, + filters=filters) + return cls._from_db_object_list(context, db_rules) + + def refresh(self, context=None): + """Loads updates for this inspection rule. + + Loads a inspection rule with the same uuid from the database and + checks for updated attributes. Updates are applied from + the loaded rule column by column, if there are any updates. + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Port(context) + :raises: InspectionRuleNotFound if the inspection rule no longer + appears in the database. + """ + current = self.get_by_uuid(self._context, uuid=self.uuid) + self.obj_refresh(current) + self.obj_reset_changes() + + +@base.IronicObjectRegistry.register +class InspectionRuleCRUDNotification(notification.NotificationBase): + """Notification emitted on inspection rule API operations.""" + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'payload': object_fields.ObjectField('InspectionRuleCRUDPayload') + } + + +@base.IronicObjectRegistry.register +class InspectionRuleCRUDPayload(notification.NotificationPayloadBase): + # Version 1.0: Initial version + VERSION = '1.0' + + SCHEMA = { + 'created_at': ('inspection_rule', 'created_at'), + 'description': ('inspection_rule', 'description'), + 'phase': ('inspection_rule', 'phase'), + 'priority': ('inspection_rule', 'priority'), + 'scope': ('inspection_rule', 'scope'), + 'sensitive': ('inspection_rule', 'sensitive'), + 'actions': ('inspection_rule', 'actions'), + 'conditions': ('inspection_rule', 'conditions'), + 'updated_at': ('inspection_rule', 'updated_at'), + 'uuid': ('inspection_rule', 'uuid') + } + + fields = { + 'created_at': object_fields.DateTimeField(nullable=True), + 'description': object_fields.StringField(nullable=True), + 'phase': object_fields.StringField(nullable=True, default='main'), + 'priority': object_fields.IntegerField(default=0), + 'scope': object_fields.StringField(nullable=True), + 'sensitive': object_fields.BooleanField(default=False), + 'actions': object_fields.ListOfFlexibleDictsField(nullable=False), + 'conditions': object_fields.ListOfFlexibleDictsField(nullable=True), + 'updated_at': object_fields.DateTimeField(nullable=True), + 'uuid': object_fields.UUIDField() + } + + def __init__(self, inspection_rule, **kwargs): + super(InspectionRuleCRUDPayload, self).__init__(**kwargs) + self.populate_schema(inspection_rule=inspection_rule) diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index 57169ca96c..6018508cd2 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -727,6 +727,9 @@ expected_object_fingerprints = { 'Runbook': '1.0-7a9c65b49b5f7b45686b6a674e703629', 'RunbookCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'RunbookCRUDPayload': '1.0-f0c97f4ff29eb3401e53b34550a95e30', + 'InspectionRule': '1.0-517185d327442696e408781343c2b83f', + 'InspectionRuleCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', + 'InspectionRuleCRUDPayload': '1.0-85d1cf2105308534a630299a897bf562', }