diff --git a/ironic/common/exception.py b/ironic/common/exception.py index 75c7b02c5c..c4c42aa93e 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -821,3 +821,7 @@ class AgentInProgress(IronicException): class InsufficentMemory(IronicException): _msg_fmt = _("Available memory at %(free)s, Insufficent as %(required)s " "is required to proceed at this time.") + + +class NodeHistoryNotFound(NotFound): + _msg_fmt = _("Node history record %(history)s could not be found.") diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index ee7634aaba..cd86488df5 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -377,6 +377,7 @@ RELEASE_MAPPING = { 'Allocation': ['1.1'], 'BIOSSetting': ['1.1'], 'Node': ['1.36', '1.35'], + 'NodeHistory': ['1.0'], 'Conductor': ['1.3'], 'Chassis': ['1.3'], 'Deployment': ['1.0'], diff --git a/ironic/db/api.py b/ironic/db/api.py index 697654d38b..5b71d32bc9 100644 --- a/ironic/db/api.py +++ b/ironic/db/api.py @@ -1322,3 +1322,61 @@ class Connection(object, metaclass=abc.ABCMeta): :param names: List of names to filter by. :returns: A list of deploy templates. """ + + @abc.abstractmethod + def create_node_history(self, values): + """Create a new history record. + + :param values: Dict of values. + """ + + @abc.abstractmethod + def destroy_node_history_by_uuid(self, history_uuid): + """Destroy a history record. + + :param history_uuid: The uuid of a history record + """ + + @abc.abstractmethod + def get_node_history_by_id(self, history_id): + """Return a node history representation. + + :param history_id: The id of a history record. + :returns: A history. + """ + + @abc.abstractmethod + def get_node_history_by_uuid(self, history_uuid): + """Return a node history representation. + + :param history_uuid: The uuid of a history record + :returns: A history. + """ + + @abc.abstractmethod + def get_node_history_list(self, limit=None, marker=None, + sort_key=None, sort_dir=None): + """Return a list of node history records + + :param limit: Maximum number of history records 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) + """ + + @abc.abstractmethod + def get_node_history_by_node_id(self, node_id, limit=None, marker=None, + sort_key=None, sort_dir=None): + """List all the history records for a given node. + + :param node_id: The integer node ID. + :param limit: Maximum number of history records 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 histories. + """ diff --git a/ironic/db/sqlalchemy/alembic/versions/9ef41f07cb58_add_node_history_table.py b/ironic/db/sqlalchemy/alembic/versions/9ef41f07cb58_add_node_history_table.py new file mode 100644 index 0000000000..9f5b855edf --- /dev/null +++ b/ironic/db/sqlalchemy/alembic/versions/9ef41f07cb58_add_node_history_table.py @@ -0,0 +1,52 @@ +# 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_node_history_table + +Revision ID: 9ef41f07cb58 +Revises: c1846a214450 +Create Date: 2020-12-20 17:45:57.278649 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '9ef41f07cb58' +down_revision = 'c1846a214450' + + +def upgrade(): + op.create_table('node_history', + 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), + sa.Column('uuid', sa.String(length=36), nullable=False), + sa.Column('conductor', sa.String(length=255), + nullable=True), + sa.Column('event_type', sa.String(length=255), + nullable=True), + sa.Column('severity', sa.String(length=255), + nullable=True), + sa.Column('event', sa.Text(), nullable=True), + sa.Column('user', sa.String(length=32), nullable=True), + sa.Column('node_id', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('uuid', name='uniq_history0uuid'), + sa.ForeignKeyConstraint(['node_id'], ['nodes.id'], ), + sa.Index('history_node_id_idx', 'node_id'), + sa.Index('history_uuid_idx', 'uuid'), + sa.Index('history_conductor_idx', 'conductor'), + mysql_ENGINE='InnoDB', + mysql_DEFAULT_CHARSET='UTF8') diff --git a/ironic/db/sqlalchemy/api.py b/ironic/db/sqlalchemy/api.py index 1de3add321..716c422dd2 100644 --- a/ironic/db/sqlalchemy/api.py +++ b/ironic/db/sqlalchemy/api.py @@ -789,6 +789,11 @@ class Connection(api.Connection): models.Allocation).filter_by(node_id=node_id) allocation_query.delete() + # delete all history for this node + history_query = model_query( + models.NodeHistory).filter_by(node_id=node_id) + history_query.delete() + query.delete() def update_node(self, node_id, values): @@ -2275,3 +2280,52 @@ class Connection(api.Connection): query = (_get_deploy_template_query_with_steps() .filter(models.DeployTemplate.name.in_(names))) return query.all() + + @oslo_db_api.retry_on_deadlock + def create_node_history(self, values): + values['uuid'] = uuidutils.generate_uuid() + + history = models.NodeHistory() + history.update(values) + with _session_for_write() as session: + try: + session.add(history) + session.flush() + except db_exc.DBDuplicateEntry: + raise exception.NodeHistoryAlreadyExists(uuid=values['uuid']) + return history + + @oslo_db_api.retry_on_deadlock + def destroy_node_history_by_uuid(self, history_uuid): + with _session_for_write(): + query = model_query(models.NodeHistory).filter_by( + uuid=history_uuid) + count = query.delete() + if count == 0: + raise exception.NodeHistoryNotFound(history=history_uuid) + + def get_node_history_by_id(self, history_id): + query = model_query(models.NodeHistory).filter_by(id=history_id) + try: + return query.one() + except NoResultFound: + raise exception.NodeHistoryNotFound(history=history_id) + + def get_node_history_by_uuid(self, history_uuid): + query = model_query(models.NodeHistory).filter_by(uuid=history_uuid) + try: + return query.one() + except NoResultFound: + raise exception.NodeHistoryNotFound(history=history_uuid) + + def get_node_history_list(self, limit=None, marker=None, + sort_key=None, sort_dir=None): + return _paginate_query(models.NodeHistory, limit, marker, sort_key, + sort_dir) + + def get_node_history_by_node_id(self, node_id, limit=None, marker=None, + sort_key=None, sort_dir=None): + query = model_query(models.NodeHistory) + query = query.filter_by(node_id=node_id) + return _paginate_query(models.NodeHistory, limit, marker, + sort_key, sort_dir, query) diff --git a/ironic/db/sqlalchemy/models.py b/ironic/db/sqlalchemy/models.py index 6a1c73d62d..8f3f6a5642 100644 --- a/ironic/db/sqlalchemy/models.py +++ b/ironic/db/sqlalchemy/models.py @@ -417,6 +417,26 @@ class DeployTemplateStep(Base): ) +class NodeHistory(Base): + """Represents a history event of a bare metal node.""" + + __tablename__ = 'node_history' + __table_args__ = ( + schema.UniqueConstraint('uuid', name='uniq_history0uuid'), + Index('history_node_id_idx', 'node_id'), + Index('history_uuid_idx', 'uuid'), + Index('history_conductor_idx', 'conductor'), + table_args()) + id = Column(Integer, primary_key=True) + uuid = Column(String(36), nullable=False) + conductor = Column(String(255), nullable=True) + event_type = Column(String(255), nullable=True) + severity = Column(String(255), nullable=True) + event = Column(Text, nullable=True) + user = Column(String(32), nullable=True) + node_id = Column(Integer, ForeignKey('nodes.id'), nullable=True) + + 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 7f199c6aaa..e8de08d5ab 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.node') + __import__('ironic.objects.node_history') __import__('ironic.objects.port') __import__('ironic.objects.portgroup') __import__('ironic.objects.trait') diff --git a/ironic/objects/node_history.py b/ironic/objects/node_history.py new file mode 100644 index 0000000000..abccd51843 --- /dev/null +++ b/ironic/objects/node_history.py @@ -0,0 +1,184 @@ +# 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 strutils +from oslo_utils import uuidutils +from oslo_versionedobjects import base as object_base + +from ironic.common import exception +from ironic.db import api as dbapi +from ironic.objects import base +from ironic.objects import fields as object_fields + + +@base.IronicObjectRegistry.register +class NodeHistory(base.IronicObject, object_base.VersionedObjectDictCompat): + # Version 1.0: Initial version + VERSION = '1.0' + + dbapi = dbapi.get_instance() + + fields = { + 'id': object_fields.IntegerField(), + 'uuid': object_fields.UUIDField(nullable=True), + 'conductor': object_fields.StringField(nullable=True), + 'event': object_fields.StringField(nullable=True), + 'user': object_fields.StringField(nullable=True), + 'node_id': object_fields.IntegerField(nullable=True), + 'event_type': object_fields.StringField(nullable=True), + 'severity': object_fields.StringField(nullable=True), + } + + # NOTE(xek): 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(cls, context, history_ident): + """Get a history based on its id or uuid. + + :param history_ident: The id or uuid of a history. + :param context: Security context + :returns: A :class:`NodeHistory` object. + :raises: InvalidIdentity + + """ + if strutils.is_int_like(history_ident): + return cls.get_by_id(context, history_ident) + elif uuidutils.is_uuid_like(history_ident): + return cls.get_by_uuid(context, history_ident) + else: + raise exception.InvalidIdentity(identity=history_ident) + + # NOTE(xek): 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_id(cls, context, history_id): + """Get a NodeHistory object by its integer ID. + + :param cls: the :class:`NodeHistory` + :param context: Security context + :param history_id: The ID of a history. + :returns: A :class:`NodeHistory` object. + :raises: NodeHistoryNotFound + + """ + db_history = cls.dbapi.get_node_history_by_id(history_id) + history = cls._from_db_object(context, cls(), db_history) + return history + + # NOTE(xek): 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): + """Get a NodeHistory object by its UUID. + + :param cls: the :class:`NodeHistory` + :param context: Security context + :param uuid: The UUID of a NodeHistory. + :returns: A :class:`NodeHistory` object. + :raises: NodeHistoryNotFound + + """ + db_history = cls.dbapi.get_node_history_by_uuid(uuid) + history = cls._from_db_object(context, cls(), db_history) + return history + + # NOTE(xek): 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): + """Return a list of NodeHistory objects. + + :param cls: the :class:`NodeHistory` + :param context: Security 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:`NodeHistory` object. + :raises: InvalidParameterValue + + """ + db_histories = cls.dbapi.get_node_history_list(limit=limit, + marker=marker, + sort_key=sort_key, + sort_dir=sort_dir) + return cls._from_db_object_list(context, db_histories) + + # NOTE(xek): 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_by_node_id(cls, context, node_id, limit=None, marker=None, + sort_key=None, sort_dir=None): + """Return a list of NodeHistory objects belongs to a given node ID. + + :param cls: the :class:`NodeHistory` + :param context: Security context. + :param node_id: The ID of the node. + :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:`NodeHistory` object. + :raises: InvalidParameterValue + + """ + db_histories = cls.dbapi.get_node_history_by_node_id( + node_id, limit=limit, marker=marker, sort_key=sort_key, + sort_dir=sort_dir) + return cls._from_db_object_list(context, db_histories) + + # NOTE(xek): 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 NodeHistory 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.: NodeHistory(context) + """ + values = self.do_version_changes_for_db() + db_history = self.dbapi.create_node_history(values) + self._from_db_object(self._context, self, db_history) + + # NOTE(xek): 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 destroy(self, context=None): + """Delete the NodeHistory from 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.: NodeHistory(context) + :raises: NodeHistoryNotFound + """ + self.dbapi.destroy_node_history_by_uuid(self.uuid) + self.obj_reset_changes() diff --git a/ironic/tests/unit/db/sqlalchemy/test_migrations.py b/ironic/tests/unit/db/sqlalchemy/test_migrations.py index 0381107e9c..47b10eec8e 100644 --- a/ironic/tests/unit/db/sqlalchemy/test_migrations.py +++ b/ironic/tests/unit/db/sqlalchemy/test_migrations.py @@ -1053,6 +1053,36 @@ class MigrationCheckersMixin(object): col_names = [column.name for column in ports.c] self.assertIn('name', col_names) + def _check_9ef41f07cb58(self, engine, data): + node_history = db_utils.get_table(engine, 'node_history') + col_names = [column.name for column in node_history.c] + + expected_names = ['version', 'created_at', 'updated_at', 'id', 'uuid', + 'conductor', 'event_type', 'severity', 'event', + 'user', 'node_id'] + self.assertEqual(sorted(expected_names), sorted(col_names)) + + self.assertIsInstance(node_history.c.created_at.type, + sqlalchemy.types.DateTime) + self.assertIsInstance(node_history.c.updated_at.type, + sqlalchemy.types.DateTime) + self.assertIsInstance(node_history.c.id.type, + sqlalchemy.types.Integer) + self.assertIsInstance(node_history.c.uuid.type, + sqlalchemy.types.String) + self.assertIsInstance(node_history.c.conductor.type, + sqlalchemy.types.String) + self.assertIsInstance(node_history.c.event_type.type, + sqlalchemy.types.String) + self.assertIsInstance(node_history.c.severity.type, + sqlalchemy.types.String) + self.assertIsInstance(node_history.c.event.type, + sqlalchemy.types.TEXT) + self.assertIsInstance(node_history.c.node_id.type, + sqlalchemy.types.Integer) + self.assertIsInstance(node_history.c.user.type, + sqlalchemy.types.String) + def test_upgrade_and_version(self): with patch_with_engine(self.engine): self.migration_api.upgrade('head') diff --git a/ironic/tests/unit/db/test_node_history.py b/ironic/tests/unit/db/test_node_history.py new file mode 100644 index 0000000000..9e554cd9c0 --- /dev/null +++ b/ironic/tests/unit/db/test_node_history.py @@ -0,0 +1,93 @@ +# 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 ironic.common import exception +from ironic.tests.unit.db import base +from ironic.tests.unit.db import utils as db_utils + + +class DBNodeHistoryTestCase(base.DbTestCase): + + def setUp(self): + super(DBNodeHistoryTestCase, self).setUp() + self.node = db_utils.create_test_node() + self.history = db_utils.create_test_history( + id=0, node_id=self.node.id, conductor='test-conductor', + user='fake-user', event='Something bad happened but fear not') + + def test_destroy_node_history_by_uuid(self): + self.dbapi.destroy_node_history_by_uuid(self.history.uuid) + self.assertRaises(exception.NodeHistoryNotFound, + self.dbapi.get_node_history_by_id, + self.history.id) + self.assertRaises(exception.NodeHistoryNotFound, + self.dbapi.get_node_history_by_uuid, + self.history.uuid) + + def test_get_history_by_id(self): + res = self.dbapi.get_node_history_by_id(self.history.id) + self.assertEqual(self.history.conductor, res.conductor) + self.assertEqual(self.history.user, res.user) + self.assertEqual(self.history.event, res.event) + + def test_get_history_by_id_not_found(self): + self.assertRaises(exception.NodeHistoryNotFound, + self.dbapi.get_node_history_by_id, -1) + + def test_get_history_by_uuid(self): + res = self.dbapi.get_node_history_by_uuid(self.history.uuid) + self.assertEqual(self.history.id, res.id) + + def test_get_history_by_uuid_not_found(self): + self.assertRaises(exception.NodeHistoryNotFound, + self.dbapi.get_node_history_by_uuid, + 'wrong-uuid') + + def _prepare_history_entries(self): + uuids = [str(self.history.uuid)] + for i in range(1, 6): + history = db_utils.create_test_history( + id=i, uuid=uuidutils.generate_uuid(), + conductor='test-conductor', user='fake-user', + event='Something bad happened but fear not %s' % i, + severity='ERROR', event_type='test') + uuids.append(str(history.uuid)) + return uuids + + def test_get_node_history_list(self): + uuids = self._prepare_history_entries() + res = self.dbapi.get_node_history_list() + res_uuids = [r.uuid for r in res] + self.assertCountEqual(uuids, res_uuids) + + def test_get_node_history_list_sorted(self): + self._prepare_history_entries() + + res = self.dbapi.get_node_history_list(sort_key='created_at', + sort_dir='desc') + expected = sorted(res, key=lambda r: r.created_at, reverse=True) + self.assertEqual(res, expected) + self.assertIn('fear not 5', res[0].event) + + def test_get_history_by_node_id_empty(self): + self.assertEqual([], self.dbapi.get_node_history_by_node_id(10)) + + def test_get_history_by_node_id(self): + res = self.dbapi.get_node_history_by_node_id(self.node.id) + self.assertEqual(self.history.uuid, res[0].uuid) + self.assertEqual(self.history.user, res[0].user) + self.assertEqual(self.history.conductor, res[0].conductor) + self.assertEqual(self.history.event, res[0].event) + self.assertEqual(self.history.event_type, res[0].event_type) + self.assertEqual(self.history.severity, res[0].severity) diff --git a/ironic/tests/unit/db/test_nodes.py b/ironic/tests/unit/db/test_nodes.py index 92e315eb10..eb5200f4e4 100644 --- a/ironic/tests/unit/db/test_nodes.py +++ b/ironic/tests/unit/db/test_nodes.py @@ -751,6 +751,15 @@ class DbNodeTestCase(base.DbTestCase): self.assertRaises(exception.AllocationNotFound, self.dbapi.get_allocation_by_id, allocation.id) + def test_history_get_destroyed_after_destroying_a_node_by_uuid(self): + node = utils.create_test_node() + + history = utils.create_test_history(node_id=node.id) + + self.dbapi.destroy_node(node.uuid) + self.assertRaises(exception.NodeHistoryNotFound, + self.dbapi.get_node_history_by_id, history.id) + def test_update_node(self): node = utils.create_test_node() diff --git a/ironic/tests/unit/db/utils.py b/ironic/tests/unit/db/utils.py index c0b060eef8..0e60b1fa54 100644 --- a/ironic/tests/unit/db/utils.py +++ b/ironic/tests/unit/db/utils.py @@ -27,6 +27,7 @@ from ironic.objects import chassis from ironic.objects import conductor from ironic.objects import deploy_template from ironic.objects import node +from ironic.objects import node_history from ironic.objects import port from ironic.objects import portgroup from ironic.objects import trait @@ -690,3 +691,33 @@ def get_test_ibmc_info(): "ibmc_password": "password", "verify_ca": False, } + + +def get_test_history(**kw): + return { + 'id': kw.get('id', 345), + 'version': kw.get('version', node_history.NodeHistory.VERSION), + 'uuid': kw.get('uuid', '6f8a5d5c-0f2d-4b2c-a62a-a38e300e3f31'), + 'node_id': kw.get('node_id', 123), + 'event': kw.get('event', 'Something is wrong'), + 'conductor': kw.get('conductor', 'host-1'), + 'severity': kw.get('severity', 'ERROR'), + 'event_type': kw.get('event_type', 'provisioning'), + 'user': kw.get('user', 'fake-user'), + 'created_at': kw.get('created_at'), + 'updated_at': kw.get('updated_at'), + } + + +def create_test_history(**kw): + """Create test history entry in DB and return NodeHistory DB object. + + :param kw: kwargs with overriding values for port's attributes. + :returns: Test NodeHistory DB object. + """ + history = get_test_history(**kw) + # Let DB generate ID if it isn't specified explicitly + if 'id' not in kw: + del history['id'] + dbapi = db_api.get_instance() + return dbapi.create_node_history(history) diff --git a/ironic/tests/unit/objects/test_node_history.py b/ironic/tests/unit/objects/test_node_history.py new file mode 100644 index 0000000000..780fe7067c --- /dev/null +++ b/ironic/tests/unit/objects/test_node_history.py @@ -0,0 +1,133 @@ +# 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. + +import types +from unittest import mock + +from testtools.matchers import HasLength + +from ironic.common import exception +from ironic import objects +from ironic.tests.unit.db import base as db_base +from ironic.tests.unit.db import utils as db_utils +from ironic.tests.unit.objects import utils as obj_utils + + +class TestNodeHistoryObject(db_base.DbTestCase, obj_utils.SchemasTestMixIn): + + def setUp(self): + super(TestNodeHistoryObject, self).setUp() + self.fake_history = db_utils.get_test_history() + + def test_get_by_id(self): + with mock.patch.object(self.dbapi, 'get_node_history_by_id', + autospec=True) as mock_get: + id_ = self.fake_history['id'] + mock_get.return_value = self.fake_history + + history = objects.NodeHistory.get_by_id(self.context, id_) + + mock_get.assert_called_once_with(id_) + self.assertIsInstance(history, objects.NodeHistory) + self.assertEqual(self.context, history._context) + + def test_get_by_uuid(self): + uuid = self.fake_history['uuid'] + with mock.patch.object(self.dbapi, 'get_node_history_by_uuid', + autospec=True) as mock_get: + mock_get.return_value = self.fake_history + + history = objects.NodeHistory.get_by_uuid(self.context, uuid) + + mock_get.assert_called_once_with(uuid) + self.assertIsInstance(history, objects.NodeHistory) + self.assertEqual(self.context, history._context) + + @mock.patch('ironic.objects.NodeHistory.get_by_uuid', + spec_set=types.FunctionType) + @mock.patch('ironic.objects.NodeHistory.get_by_id', + spec_set=types.FunctionType) + def test_get(self, mock_get_by_id, mock_get_by_uuid): + id_ = self.fake_history['id'] + uuid = self.fake_history['uuid'] + + objects.NodeHistory.get(self.context, id_) + mock_get_by_id.assert_called_once_with(self.context, id_) + self.assertFalse(mock_get_by_uuid.called) + + objects.NodeHistory.get(self.context, uuid) + mock_get_by_uuid.assert_called_once_with(self.context, uuid) + + # Invalid identifier (not ID or UUID) + self.assertRaises(exception.InvalidIdentity, + objects.NodeHistory.get, + self.context, 'not-valid-identifier') + + def test_list(self): + with mock.patch.object(self.dbapi, 'get_node_history_list', + autospec=True) as mock_get_list: + mock_get_list.return_value = [self.fake_history] + history = objects.NodeHistory.list( + self.context, limit=4, sort_key='uuid', sort_dir='asc') + + mock_get_list.assert_called_once_with( + limit=4, marker=None, sort_key='uuid', sort_dir='asc') + self.assertThat(history, HasLength(1)) + self.assertIsInstance(history[0], objects.NodeHistory) + self.assertEqual(self.context, history[0]._context) + + def test_list_none(self): + with mock.patch.object(self.dbapi, 'get_node_history_list', + autospec=True) as mock_get_list: + mock_get_list.return_value = [] + history = objects.NodeHistory.list( + self.context, limit=4, sort_key='uuid', sort_dir='asc') + + mock_get_list.assert_called_once_with( + limit=4, marker=None, sort_key='uuid', sort_dir='asc') + self.assertEqual([], history) + + def test_list_by_node_id(self): + with mock.patch.object(self.dbapi, 'get_node_history_by_node_id', + autospec=True) as mock_get_list_by_node_id: + mock_get_list_by_node_id.return_value = [self.fake_history] + node_id = self.fake_history['node_id'] + history = objects.NodeHistory.list_by_node_id( + self.context, node_id, limit=10, sort_dir='desc') + + mock_get_list_by_node_id.assert_called_once_with( + node_id, limit=10, marker=None, sort_key=None, sort_dir='desc') + self.assertThat(history, HasLength(1)) + self.assertIsInstance(history[0], objects.NodeHistory) + self.assertEqual(self.context, history[0]._context) + + def test_create(self): + with mock.patch.object(self.dbapi, 'create_node_history', + autospec=True) as mock_db_create: + mock_db_create.return_value = self.fake_history + new_history = objects.NodeHistory( + self.context, **self.fake_history) + new_history.create() + + mock_db_create.assert_called_once_with(self.fake_history) + + def test_destroy(self): + uuid = self.fake_history['uuid'] + with mock.patch.object(self.dbapi, 'get_node_history_by_uuid', + autospec=True) as mock_get: + mock_get.return_value = self.fake_history + with mock.patch.object(self.dbapi, 'destroy_node_history_by_uuid', + autospec=True) as mock_db_destroy: + history = objects.NodeHistory.get_by_uuid(self.context, uuid) + history.destroy() + + mock_db_destroy.assert_called_once_with(uuid) diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index 7eefb3c59e..7c58cf4aa4 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -720,6 +720,7 @@ expected_object_fingerprints = { 'DeployTemplateCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'DeployTemplateCRUDPayload': '1.0-200857e7e715f58a5b6d6b700ab73a3b', 'Deployment': '1.0-ff10ae028c5968f1596131d85d7f5f9d', + 'NodeHistory': '1.0-9b576c6481071e7f7eac97317fa29418', }