diff options
-rw-r--r-- | ironic/cmd/dbsync.py | 2 | ||||
-rw-r--r-- | ironic/common/exception.py | 12 | ||||
-rw-r--r-- | ironic/common/release_mappings.py | 3 | ||||
-rw-r--r-- | ironic/db/api.py | 79 | ||||
-rw-r--r-- | ironic/db/sqlalchemy/alembic/versions/dd67b91a1981_add_allocations_table.py | 56 | ||||
-rw-r--r-- | ironic/db/sqlalchemy/api.py | 207 | ||||
-rw-r--r-- | ironic/db/sqlalchemy/models.py | 26 | ||||
-rw-r--r-- | ironic/objects/__init__.py | 1 | ||||
-rw-r--r-- | ironic/objects/allocation.py | 300 | ||||
-rw-r--r-- | ironic/objects/node.py | 9 | ||||
-rw-r--r-- | ironic/tests/unit/api/utils.py | 1 | ||||
-rw-r--r-- | ironic/tests/unit/db/sqlalchemy/test_migrations.py | 51 | ||||
-rw-r--r-- | ironic/tests/unit/db/test_allocations.py | 230 | ||||
-rw-r--r-- | ironic/tests/unit/db/test_nodes.py | 24 | ||||
-rw-r--r-- | ironic/tests/unit/db/utils.py | 30 | ||||
-rw-r--r-- | ironic/tests/unit/objects/test_allocation.py | 144 | ||||
-rw-r--r-- | ironic/tests/unit/objects/test_node.py | 61 | ||||
-rw-r--r-- | ironic/tests/unit/objects/test_objects.py | 5 |
18 files changed, 1235 insertions, 6 deletions
diff --git a/ironic/cmd/dbsync.py b/ironic/cmd/dbsync.py index 52012a2c3..f554cbdfc 100644 --- a/ironic/cmd/dbsync.py +++ b/ironic/cmd/dbsync.py @@ -81,6 +81,8 @@ ONLINE_MIGRATIONS = ( # These are the models added in supported releases. We skip the version check # for them since the tables do not exist when it happens. NEW_MODELS = [ + # TODO(dtantsur): remove in Train + 'Allocation', ] diff --git a/ironic/common/exception.py b/ironic/common/exception.py index f38caf04c..cba02d5a0 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -791,3 +791,15 @@ class AgentConnectionFailed(IronicException): class NodeProtected(HTTPForbidden): _msg_fmt = _("Node %(node)s is protected and cannot be undeployed, " "rebuilt or deleted") + + +class AllocationNotFound(NotFound): + _msg_fmt = _("Allocation %(allocation)s could not be found.") + + +class AllocationDuplicateName(Conflict): + _msg_fmt = _("An allocation with name %(name)s already exists.") + + +class AllocationAlreadyExists(Conflict): + _msg_fmt = _("An allocation with UUID %(uuid)s already exists.") diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index 883a4910b..0486993ac 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -134,7 +134,8 @@ RELEASE_MAPPING = { 'api': '1.50', 'rpc': '1.47', 'objects': { - 'Node': ['1.30', '1.29', '1.28'], + 'Allocation': ['1.0'], + 'Node': ['1.31', '1.30', '1.29', '1.28'], 'Conductor': ['1.3'], 'Chassis': ['1.3'], 'Port': ['1.8'], diff --git a/ironic/db/api.py b/ironic/db/api.py index c98880c7f..e43dc55da 100644 --- a/ironic/db/api.py +++ b/ironic/db/api.py @@ -1079,3 +1079,82 @@ class Connection(object): :returns: A list of BIOSSetting objects. :raises: NodeNotFound if the node is not found. """ + + @abc.abstractmethod + def get_allocation_by_id(self, allocation_id): + """Return an allocation representation. + + :param allocation_id: The id of an allocation. + :returns: An allocation. + :raises: AllocationNotFound + """ + + @abc.abstractmethod + def get_allocation_by_uuid(self, allocation_uuid): + """Return an allocation representation. + + :param allocation_uuid: The uuid of an allocation. + :returns: An allocation. + :raises: AllocationNotFound + """ + + @abc.abstractmethod + def get_allocation_by_name(self, name): + """Return an allocation representation. + + :param name: The logical name of an allocation. + :returns: An allocation. + :raises: AllocationNotFound + """ + + @abc.abstractmethod + def get_allocation_list(self, filters=None, limit=None, marker=None, + sort_key=None, sort_dir=None): + """Return a list of allocations. + + :param filters: Filters to apply. Defaults to None. + + :node_uuid: uuid of node + :state: allocation state + :resource_class: requested resource class + :param limit: Maximum number of allocations 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 allocations. + """ + + @abc.abstractmethod + def create_allocation(self, values): + """Create a new allocation. + + :param values: Dict of values to create an allocation with + :returns: An allocation + :raises: AllocationDuplicateName + :raises: AllocationAlreadyExists + """ + + @abc.abstractmethod + def update_allocation(self, allocation_id, values, update_node=True): + """Update properties of an allocation. + + :param allocation_id: Allocation ID + :param values: Dict of values to update. + :param update_node: If True and node_id is updated, update the node + with instance_uuid and traits from the allocation + :returns: An allocation. + :raises: AllocationNotFound + :raises: AllocationDuplicateName + :raises: InstanceAssociated + :raises: NodeAssociated + """ + + @abc.abstractmethod + def destroy_allocation(self, allocation_id): + """Destroy an allocation. + + :param allocation_id: Allocation ID + :raises: AllocationNotFound + """ diff --git a/ironic/db/sqlalchemy/alembic/versions/dd67b91a1981_add_allocations_table.py b/ironic/db/sqlalchemy/alembic/versions/dd67b91a1981_add_allocations_table.py new file mode 100644 index 000000000..55560dc68 --- /dev/null +++ b/ironic/db/sqlalchemy/alembic/versions/dd67b91a1981_add_allocations_table.py @@ -0,0 +1,56 @@ +# 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 Allocations table + +Revision ID: dd67b91a1981 +Revises: f190f9d00a11 +Create Date: 2018-12-10 15:24:30.555995 + +""" + +from alembic import op +from oslo_db.sqlalchemy import types +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'dd67b91a1981' +down_revision = 'f190f9d00a11' + + +def upgrade(): + op.create_table( + 'allocations', + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('version', sa.String(length=15), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('uuid', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=True), + sa.Column('node_id', sa.Integer(), nullable=True), + sa.Column('state', sa.String(length=15), nullable=False), + sa.Column('last_error', sa.Text(), nullable=True), + sa.Column('resource_class', sa.String(length=80), nullable=True), + sa.Column('traits', types.JsonEncodedList(), nullable=True), + sa.Column('candidate_nodes', types.JsonEncodedList(), nullable=True), + sa.Column('extra', types.JsonEncodedDict(), nullable=True), + sa.Column('conductor_affinity', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['conductor_affinity'], ['conductors.id'], ), + sa.ForeignKeyConstraint(['node_id'], ['nodes.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name', name='uniq_allocations0name'), + sa.UniqueConstraint('uuid', name='uniq_allocations0uuid') + ) + op.add_column('nodes', sa.Column('allocation_id', sa.Integer(), + nullable=True)) + op.create_foreign_key(None, 'nodes', 'allocations', + ['allocation_id'], ['id']) diff --git a/ironic/db/sqlalchemy/api.py b/ironic/db/sqlalchemy/api.py index 2c096f002..beaca9339 100644 --- a/ironic/db/sqlalchemy/api.py +++ b/ironic/db/sqlalchemy/api.py @@ -224,7 +224,8 @@ class Connection(api.Connection): 'chassis_uuid', 'associated', 'reserved', 'reserved_by_any_of', 'provisioned_before', 'inspection_started_before', 'fault', - 'conductor_group', 'owner'} + 'conductor_group', 'owner', + 'uuid_in', 'with_power_state'} unsupported_filters = set(filters).difference(supported_filters) if unsupported_filters: msg = _("SqlAlchemy API does not support " @@ -263,9 +264,38 @@ class Connection(api.Connection): - (datetime.timedelta( seconds=filters['inspection_started_before']))) query = query.filter(models.Node.inspection_started_at < limit) + if 'uuid_in' in filters: + query = query.filter(models.Node.uuid.in_(filters['uuid_in'])) + if 'with_power_state' in filters: + if filters['with_power_state']: + query = query.filter(models.Node.power_state != sql.null()) + else: + query = query.filter(models.Node.power_state == sql.null()) return query + def _add_allocations_filters(self, query, filters): + if filters is None: + filters = dict() + supported_filters = {'state', 'resource_class', 'node_uuid'} + 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) + + try: + node_uuid = filters.pop('node_uuid') + except KeyError: + pass + else: + node_obj = self.get_node_by_uuid(node_uuid) + filters['node_id'] = node_obj.id + + if filters: + query = query.filter_by(**filters) + return query + def get_nodeinfo_list(self, columns=None, filters=None, limit=None, marker=None, sort_key=None, sort_dir=None): # list-ify columns default values because it is bad form @@ -452,6 +482,11 @@ class Connection(api.Connection): models.BIOSSetting).filter_by(node_id=node_id) bios_settings_query.delete() + # delete all allocations for this node + allocation_query = model_query( + models.Allocation).filter_by(node_id=node_id) + allocation_query.delete() + query.delete() def update_node(self, node_id, values): @@ -1482,3 +1517,173 @@ class Connection(api.Connection): .filter_by(node_id=node_id) .all()) return result + + def get_allocation_by_id(self, allocation_id): + """Return an allocation representation. + + :param allocation_id: The id of an allocation. + :returns: An allocation. + :raises: AllocationNotFound + """ + query = model_query(models.Allocation).filter_by(id=allocation_id) + try: + return query.one() + except NoResultFound: + raise exception.AllocationNotFound(allocation=allocation_id) + + def get_allocation_by_uuid(self, allocation_uuid): + """Return an allocation representation. + + :param allocation_uuid: The uuid of an allocation. + :returns: An allocation. + :raises: AllocationNotFound + """ + query = model_query(models.Allocation).filter_by(uuid=allocation_uuid) + try: + return query.one() + except NoResultFound: + raise exception.AllocationNotFound(allocation=allocation_uuid) + + def get_allocation_by_name(self, name): + """Return an allocation representation. + + :param name: The logical name of an allocation. + :returns: An allocation. + :raises: AllocationNotFound + """ + query = model_query(models.Allocation).filter_by(name=name) + try: + return query.one() + except NoResultFound: + raise exception.AllocationNotFound(allocation=name) + + def get_allocation_list(self, filters=None, limit=None, marker=None, + sort_key=None, sort_dir=None): + """Return a list of allocations. + + :param filters: Filters to apply. Defaults to None. + + :node_uuid: uuid of node + :state: allocation state + :resource_class: requested resource class + :param limit: Maximum number of allocations 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 allocations. + """ + query = self._add_allocations_filters(model_query(models.Allocation), + filters) + return _paginate_query(models.Allocation, limit, marker, + sort_key, sort_dir, query) + + @oslo_db_api.retry_on_deadlock + def create_allocation(self, values): + """Create a new allocation. + + :param values: Dict of values to create an allocation with + :returns: An allocation + :raises: AllocationDuplicateName + :raises: AllocationAlreadyExists + """ + if not values.get('uuid'): + values['uuid'] = uuidutils.generate_uuid() + + allocation = models.Allocation() + allocation.update(values) + with _session_for_write() as session: + try: + session.add(allocation) + session.flush() + except db_exc.DBDuplicateEntry as exc: + if 'name' in exc.columns: + raise exception.AllocationDuplicateName( + name=values['name']) + else: + raise exception.AllocationAlreadyExists( + uuid=values['uuid']) + return allocation + + @oslo_db_api.retry_on_deadlock + def update_allocation(self, allocation_id, values, update_node=True): + """Update properties of an allocation. + + :param allocation_id: Allocation ID + :param values: Dict of values to update. + :param update_node: If True and node_id is updated, update the node + with instance_uuid and traits from the allocation + :returns: An allocation. + :raises: AllocationNotFound + :raises: AllocationDuplicateName + :raises: InstanceAssociated + :raises: NodeAssociated + """ + if 'uuid' in values: + msg = _("Cannot overwrite UUID for an existing allocation.") + raise exception.InvalidParameterValue(err=msg) + + # These values are used in exception handling. They should always be + # initialized, but set them to None just in case. + instance_uuid = node_uuid = None + + with _session_for_write() as session: + try: + query = model_query(models.Allocation, session=session) + query = add_identity_filter(query, allocation_id) + ref = query.one() + ref.update(values) + instance_uuid = ref.uuid + + if 'node_id' in values and update_node: + node = model_query(models.Node, session=session).filter_by( + id=ref.node_id).with_lockmode('update').one() + node_uuid = node.uuid + if node.instance_uuid and node.instance_uuid != ref.uuid: + raise exception.NodeAssociated( + node=node.uuid, instance=node.instance_uuid) + iinfo = node.instance_info.copy() + iinfo['traits'] = ref.traits or [] + node.update({'allocation_id': ref.id, + 'instance_uuid': instance_uuid, + 'instance_info': iinfo}) + session.flush() + except NoResultFound: + raise exception.AllocationNotFound(allocation=allocation_id) + except db_exc.DBDuplicateEntry as exc: + if 'name' in exc.columns: + raise exception.AllocationDuplicateName( + name=values['name']) + elif 'instance_uuid' in exc.columns: + # Case when the referenced node is associated with an + # instance already. + raise exception.InstanceAssociated( + instance_uuid=instance_uuid, node=node_uuid) + else: + raise + return ref + + @oslo_db_api.retry_on_deadlock + def destroy_allocation(self, allocation_id): + """Destroy an allocation. + + :param allocation_id: Allocation ID or UUID + :raises: AllocationNotFound + """ + with _session_for_write() as session: + query = model_query(models.Allocation) + query = add_identity_filter(query, allocation_id) + + try: + ref = query.one() + except NoResultFound: + raise exception.AllocationNotFound(allocation=allocation_id) + + allocation_id = ref['id'] + + node_query = model_query(models.Node, session=session).filter_by( + allocation_id=allocation_id) + node_query.update({'allocation_id': None, 'instance_uuid': None}) + + query.delete() diff --git a/ironic/db/sqlalchemy/models.py b/ironic/db/sqlalchemy/models.py index 2a17dfa65..db76a9dbd 100644 --- a/ironic/db/sqlalchemy/models.py +++ b/ironic/db/sqlalchemy/models.py @@ -180,6 +180,9 @@ class Node(Base): server_default=false()) protected_reason = Column(Text, nullable=True) owner = Column(String(255), nullable=True) + allocation_id = Column(Integer, ForeignKey('allocations.id'), + nullable=True) + bios_interface = Column(String(255), nullable=True) boot_interface = Column(String(255), nullable=True) console_interface = Column(String(255), nullable=True) @@ -322,6 +325,29 @@ class BIOSSetting(Base): value = Column(Text, nullable=True) +class Allocation(Base): + """Represents an allocation of a node for deployment.""" + + __tablename__ = 'allocations' + __table_args__ = ( + schema.UniqueConstraint('name', name='uniq_allocations0name'), + schema.UniqueConstraint('uuid', name='uniq_allocations0uuid'), + table_args()) + id = Column(Integer, primary_key=True) + uuid = Column(String(36), nullable=False) + name = Column(String(255), nullable=True) + node_id = Column(Integer, ForeignKey('nodes.id'), nullable=True) + state = Column(String(15), nullable=False) + last_error = Column(Text, nullable=True) + resource_class = Column(String(80), nullable=True) + traits = Column(db_types.JsonEncodedList) + candidate_nodes = Column(db_types.JsonEncodedList) + extra = Column(db_types.JsonEncodedDict) + # The last conductor to handle this allocation (internal field). + conductor_affinity = Column(Integer, ForeignKey('conductors.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 63c4d2b13..96ddc1c28 100644 --- a/ironic/objects/__init__.py +++ b/ironic/objects/__init__.py @@ -24,6 +24,7 @@ def register_all(): # NOTE(danms): You must make sure your object gets imported in this # function in order for it to be registered by services that may # need to receive it via RPC. + __import__('ironic.objects.allocation') __import__('ironic.objects.bios') __import__('ironic.objects.chassis') __import__('ironic.objects.conductor') diff --git a/ironic/objects/allocation.py b/ironic/objects/allocation.py new file mode 100644 index 000000000..1bfe44a0a --- /dev/null +++ b/ironic/objects/allocation.py @@ -0,0 +1,300 @@ +# 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.common import utils +from ironic.db import api as dbapi +from ironic.objects import base +from ironic.objects import fields as object_fields +from ironic.objects import notification + + +@base.IronicObjectRegistry.register +class Allocation(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), + 'name': object_fields.StringField(nullable=True), + 'node_id': object_fields.IntegerField(nullable=True), + 'state': object_fields.StringField(nullable=True), + 'last_error': object_fields.StringField(nullable=True), + 'resource_class': object_fields.StringField(nullable=True), + 'traits': object_fields.ListOfStringsField(nullable=True), + 'candidate_nodes': object_fields.ListOfStringsField(nullable=True), + 'extra': object_fields.FlexibleDictField(nullable=True), + 'conductor_affinity': object_fields.IntegerField(nullable=True), + } + + def _convert_to_version(self, target_version, + remove_unavailable_fields=True): + """Convert to the target version. + + Convert the object to the target version. The target version may be + the same, older, or newer than the version of the object. This is + used for DB interactions as well as for serialization/deserialization. + + :param target_version: the desired version of the object + :param remove_unavailable_fields: True to remove fields that are + unavailable in the target version; set this to True when + (de)serializing. False to set the unavailable fields to appropriate + values; set this to False for DB interactions. + """ + + # 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, allocation_ident): + """Find a allocation based on its id, uuid, name or address. + + :param allocation_ident: The id, uuid, name or address of a allocation. + :param context: Security context + :returns: A :class:`Allocation` object. + :raises: InvalidIdentity + + """ + if strutils.is_int_like(allocation_ident): + return cls.get_by_id(context, allocation_ident) + elif uuidutils.is_uuid_like(allocation_ident): + return cls.get_by_uuid(context, allocation_ident) + elif utils.is_valid_logical_name(allocation_ident): + return cls.get_by_name(context, allocation_ident) + else: + raise exception.InvalidIdentity(identity=allocation_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, allocation_id): + """Find a allocation by its integer ID and return a Allocation object. + + :param cls: the :class:`Allocation` + :param context: Security context + :param allocation_id: The ID of a allocation. + :returns: A :class:`Allocation` object. + :raises: AllocationNotFound + + """ + db_allocation = cls.dbapi.get_allocation_by_id(allocation_id) + allocation = cls._from_db_object(context, cls(), db_allocation) + return allocation + + # 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): + """Find a allocation by UUID and return a :class:`Allocation` object. + + :param cls: the :class:`Allocation` + :param context: Security context + :param uuid: The UUID of a allocation. + :returns: A :class:`Allocation` object. + :raises: AllocationNotFound + + """ + db_allocation = cls.dbapi.get_allocation_by_uuid(uuid) + allocation = cls._from_db_object(context, cls(), db_allocation) + return allocation + + # 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_name(cls, context, name): + """Find allocation based on name and return a :class:`Allocation` object. + + :param cls: the :class:`Allocation` + :param context: Security context + :param name: The name of a allocation. + :returns: A :class:`Allocation` object. + :raises: AllocationNotFound + + """ + db_allocation = cls.dbapi.get_allocation_by_name(name) + allocation = cls._from_db_object(context, cls(), db_allocation) + return allocation + + # 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, filters=None, limit=None, marker=None, + sort_key=None, sort_dir=None): + """Return a list of Allocation objects. + + :param cls: the :class:`Allocation` + :param context: Security context. + :param filters: Filters to apply. + :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:`Allocation` object. + :raises: InvalidParameterValue + + """ + db_allocations = cls.dbapi.get_allocation_list(filters=filters, + limit=limit, + marker=marker, + sort_key=sort_key, + sort_dir=sort_dir) + return cls._from_db_object_list(context, db_allocations) + + # 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 Allocation 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.: Allocation(context) + :raises: AllocationDuplicateName, AllocationAlreadyExists + + """ + values = self.do_version_changes_for_db() + db_allocation = self.dbapi.create_allocation(values) + self._from_db_object(self._context, self, db_allocation) + + # 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 Allocation 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.: Allocation(context) + :raises: AllocationNotFound + + """ + self.dbapi.destroy_allocation(self.uuid) + self.obj_reset_changes() + + # 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 save(self, context=None): + """Save updates to this Allocation. + + Updates will be made column by column based on the result + of self.what_changed(). + + :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.: Allocation(context) + :raises: AllocationNotFound, AllocationDuplicateName + + """ + updates = self.do_version_changes_for_db() + updated_allocation = self.dbapi.update_allocation(self.uuid, updates) + self._from_db_object(self._context, self, updated_allocation) + + # 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 refresh(self, context=None): + """Loads updates for this Allocation. + + Loads a allocation with the same uuid from the database and + checks for updated attributes. Updates are applied from + the loaded allocation 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.: Allocation(context) + :raises: AllocationNotFound + + """ + current = self.get_by_uuid(self._context, uuid=self.uuid) + self.obj_refresh(current) + self.obj_reset_changes() + + +@base.IronicObjectRegistry.register +class AllocationCRUDNotification(notification.NotificationBase): + """Notification when ironic creates, updates or deletes a allocation.""" + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'payload': object_fields.ObjectField('AllocationCRUDPayload') + } + + +@base.IronicObjectRegistry.register +class AllocationCRUDPayload(notification.NotificationPayloadBase): + # Version 1.0: Initial version + VERSION = '1.0' + + SCHEMA = { + 'candidate_nodes': ('allocation', 'candidate_nodes'), + 'created_at': ('allocation', 'created_at'), + 'extra': ('allocation', 'extra'), + 'last_error': ('allocation', 'last_error'), + 'name': ('allocation', 'name'), + 'resource_class': ('allocation', 'resource_class'), + 'state': ('allocation', 'state'), + 'traits': ('allocation', 'traits'), + 'updated_at': ('allocation', 'updated_at'), + 'uuid': ('allocation', 'uuid') + } + + fields = { + 'uuid': object_fields.UUIDField(nullable=True), + 'name': object_fields.StringField(nullable=True), + 'node_uuid': object_fields.StringField(nullable=True), + 'state': object_fields.StringField(nullable=True), + 'last_error': object_fields.StringField(nullable=True), + 'resource_class': object_fields.StringField(nullable=True), + 'traits': object_fields.ListOfStringsField(nullable=True), + 'candidate_nodes': object_fields.ListOfStringsField(nullable=True), + 'extra': object_fields.FlexibleDictField(nullable=True), + 'created_at': object_fields.DateTimeField(nullable=True), + 'updated_at': object_fields.DateTimeField(nullable=True), + } + + def __init__(self, allocation, node_uuid): + super(AllocationCRUDPayload, self).__init__(node_uuid=node_uuid) + self.populate_schema(allocation=allocation) diff --git a/ironic/objects/node.py b/ironic/objects/node.py index 73b918745..3fe530525 100644 --- a/ironic/objects/node.py +++ b/ironic/objects/node.py @@ -67,7 +67,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): # Version 1.28: Add automated_clean field # Version 1.29: Add protected and protected_reason fields # Version 1.30: Add owner field - VERSION = '1.30' + # Version 1.31: Add allocation_id field + VERSION = '1.31' dbapi = db_api.get_instance() @@ -136,6 +137,7 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): 'automated_clean': objects.fields.BooleanField(nullable=True), 'protected': objects.fields.BooleanField(), 'protected_reason': object_fields.StringField(nullable=True), + 'allocation_id': object_fields.IntegerField(nullable=True), 'bios_interface': object_fields.StringField(nullable=True), 'boot_interface': object_fields.StringField(nullable=True), @@ -585,6 +587,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): should be set to False (or removed). Version 1.30: owner was added. For versions prior to this, it should be set to None or removed. + Version 1.31: allocation_id was added. For versions prior to this, it + should be set to None (or removed). :param target_version: the desired version of the object :param remove_unavailable_fields: True to remove fields that are @@ -597,7 +601,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): # Convert the different fields depending on version fields = [('rescue_interface', 22), ('traits', 23), ('bios_interface', 24), ('automated_clean', 28), - ('protected_reason', 29), ('owner', 30)] + ('protected_reason', 29), ('owner', 30), + ('allocation_id', 31)] for name, minor in fields: self._adjust_field_to_version(name, None, target_version, 1, minor, remove_unavailable_fields) diff --git a/ironic/tests/unit/api/utils.py b/ironic/tests/unit/api/utils.py index 0635d0294..f2c11bda1 100644 --- a/ironic/tests/unit/api/utils.py +++ b/ironic/tests/unit/api/utils.py @@ -100,6 +100,7 @@ def node_post_data(**kw): node.pop('chassis_id') node.pop('tags') node.pop('traits') + node.pop('allocation_id') # NOTE(jroll): pop out fields that were introduced in later API versions, # unless explicitly requested. Otherwise, these will cause tests using diff --git a/ironic/tests/unit/db/sqlalchemy/test_migrations.py b/ironic/tests/unit/db/sqlalchemy/test_migrations.py index 10b409f20..9593f2605 100644 --- a/ironic/tests/unit/db/sqlalchemy/test_migrations.py +++ b/ironic/tests/unit/db/sqlalchemy/test_migrations.py @@ -791,6 +791,57 @@ class MigrationCheckersMixin(object): col_names = [column.name for column in nodes.c] self.assertIn('owner', col_names) + def _pre_upgrade_dd67b91a1981(self, engine): + data = { + 'node_uuid': uuidutils.generate_uuid(), + } + + nodes = db_utils.get_table(engine, 'nodes') + nodes.insert().execute({'uuid': data['node_uuid']}) + + return data + + def _check_dd67b91a1981(self, engine, data): + nodes = db_utils.get_table(engine, 'nodes') + col_names = [column.name for column in nodes.c] + self.assertIn('allocation_id', col_names) + + node = nodes.select( + nodes.c.uuid == data['node_uuid']).execute().first() + self.assertIsNone(node['allocation_id']) + + allocations = db_utils.get_table(engine, 'allocations') + col_names = [column.name for column in allocations.c] + expected_names = ['id', 'uuid', 'node_id', 'created_at', 'updated_at', + 'name', 'version', 'state', 'last_error', + 'resource_class', 'traits', 'candidate_nodes', + 'extra', 'conductor_affinity'] + self.assertEqual(sorted(expected_names), sorted(col_names)) + self.assertIsInstance(allocations.c.created_at.type, + sqlalchemy.types.DateTime) + self.assertIsInstance(allocations.c.updated_at.type, + sqlalchemy.types.DateTime) + self.assertIsInstance(allocations.c.id.type, + sqlalchemy.types.Integer) + self.assertIsInstance(allocations.c.uuid.type, + sqlalchemy.types.String) + self.assertIsInstance(allocations.c.node_id.type, + sqlalchemy.types.Integer) + self.assertIsInstance(allocations.c.state.type, + sqlalchemy.types.String) + self.assertIsInstance(allocations.c.last_error.type, + sqlalchemy.types.TEXT) + self.assertIsInstance(allocations.c.resource_class.type, + sqlalchemy.types.String) + self.assertIsInstance(allocations.c.traits.type, + sqlalchemy.types.TEXT) + self.assertIsInstance(allocations.c.candidate_nodes.type, + sqlalchemy.types.TEXT) + self.assertIsInstance(allocations.c.extra.type, + sqlalchemy.types.TEXT) + self.assertIsInstance(allocations.c.conductor_affinity.type, + sqlalchemy.types.Integer) + 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_allocations.py b/ironic/tests/unit/db/test_allocations.py new file mode 100644 index 000000000..451b087fa --- /dev/null +++ b/ironic/tests/unit/db/test_allocations.py @@ -0,0 +1,230 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Tests for manipulating allocations via the DB API""" + +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 AllocationsTestCase(base.DbTestCase): + + def setUp(self): + super(AllocationsTestCase, self).setUp() + self.node = db_utils.create_test_node() + self.allocation = db_utils.create_test_allocation(name='host1') + + def _create_test_allocation_range(self, count, **kw): + """Create the specified number of test allocation entries in DB + + It uses create_test_allocation method. And returns List of Allocation + DB objects. + + :param count: Specifies the number of allocations to be created + :returns: List of Allocation DB objects + + """ + return [db_utils.create_test_allocation(uuid=uuidutils.generate_uuid(), + name='allocation' + str(i), + **kw).uuid + for i in range(count)] + + def test_get_allocation_by_id(self): + res = self.dbapi.get_allocation_by_id(self.allocation.id) + self.assertEqual(self.allocation.uuid, res.uuid) + + def test_get_allocation_by_id_that_does_not_exist(self): + self.assertRaises(exception.AllocationNotFound, + self.dbapi.get_allocation_by_id, 99) + + def test_get_allocation_by_uuid(self): + res = self.dbapi.get_allocation_by_uuid(self.allocation.uuid) + self.assertEqual(self.allocation.id, res.id) + + def test_get_allocation_by_uuid_that_does_not_exist(self): + self.assertRaises(exception.AllocationNotFound, + self.dbapi.get_allocation_by_uuid, + 'EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE') + + def test_get_allocation_by_name(self): + res = self.dbapi.get_allocation_by_name(self.allocation.name) + self.assertEqual(self.allocation.id, res.id) + + def test_get_allocation_by_name_that_does_not_exist(self): + self.assertRaises(exception.AllocationNotFound, + self.dbapi.get_allocation_by_name, 'testfail') + + def test_get_allocation_list(self): + uuids = self._create_test_allocation_range(6) + # Also add the uuid for the allocation created in setUp() + uuids.append(self.allocation.uuid) + + res = self.dbapi.get_allocation_list() + self.assertEqual(set(uuids), {r.uuid for r in res}) + + def test_get_allocation_list_sorted(self): + uuids = self._create_test_allocation_range(6) + # Also add the uuid for the allocation created in setUp() + uuids.append(self.allocation.uuid) + + res = self.dbapi.get_allocation_list(sort_key='uuid') + res_uuids = [r.uuid for r in res] + self.assertEqual(sorted(uuids), res_uuids) + + def test_get_allocation_list_filter_by_state(self): + self._create_test_allocation_range(6, state='error') + + res = self.dbapi.get_allocation_list(filters={'state': 'allocating'}) + self.assertEqual([self.allocation.uuid], [r.uuid for r in res]) + + res = self.dbapi.get_allocation_list(filters={'state': 'error'}) + self.assertEqual(6, len(res)) + + def test_get_allocation_list_filter_by_node(self): + self._create_test_allocation_range(6) + self.dbapi.update_allocation(self.allocation.id, + {'node_id': self.node.id}) + + res = self.dbapi.get_allocation_list( + filters={'node_uuid': self.node.uuid}) + self.assertEqual([self.allocation.uuid], [r.uuid for r in res]) + + def test_get_allocation_list_filter_by_rsc(self): + self._create_test_allocation_range(6) + self.dbapi.update_allocation(self.allocation.id, + {'resource_class': 'very-large'}) + + res = self.dbapi.get_allocation_list( + filters={'resource_class': 'very-large'}) + self.assertEqual([self.allocation.uuid], [r.uuid for r in res]) + + def test_get_allocation_list_invalid_fields(self): + self.assertRaises(exception.InvalidParameterValue, + self.dbapi.get_allocation_list, sort_key='foo') + self.assertRaises(ValueError, + self.dbapi.get_allocation_list, + filters={'foo': 42}) + + def test_destroy_allocation(self): + self.dbapi.destroy_allocation(self.allocation.id) + self.assertRaises(exception.AllocationNotFound, + self.dbapi.get_allocation_by_id, self.allocation.id) + + def test_destroy_allocation_with_node(self): + self.dbapi.update_node(self.node.id, + {'allocation_id': self.allocation.id, + 'instance_uuid': uuidutils.generate_uuid()}) + self.dbapi.destroy_allocation(self.allocation.id) + self.assertRaises(exception.AllocationNotFound, + self.dbapi.get_allocation_by_id, self.allocation.id) + node = self.dbapi.get_node_by_id(self.node.id) + self.assertIsNone(node.allocation_id) + self.assertIsNone(node.instance_uuid) + + def test_destroy_allocation_that_does_not_exist(self): + self.assertRaises(exception.AllocationNotFound, + self.dbapi.destroy_allocation, 99) + + def test_destroy_allocation_uuid(self): + self.dbapi.destroy_allocation(self.allocation.uuid) + + def test_update_allocation(self): + old_name = self.allocation.name + new_name = 'newname' + self.assertNotEqual(old_name, new_name) + res = self.dbapi.update_allocation(self.allocation.id, + {'name': new_name}) + self.assertEqual(new_name, res.name) + + def test_update_allocation_uuid(self): + self.assertRaises(exception.InvalidParameterValue, + self.dbapi.update_allocation, self.allocation.id, + {'uuid': ''}) + + def test_update_allocation_not_found(self): + id_2 = 99 + self.assertNotEqual(self.allocation.id, id_2) + self.assertRaises(exception.AllocationNotFound, + self.dbapi.update_allocation, id_2, + {'name': 'newname'}) + + def test_update_allocation_duplicated_name(self): + name1 = self.allocation.name + allocation2 = db_utils.create_test_allocation( + uuid=uuidutils.generate_uuid(), name='name2') + self.assertRaises(exception.AllocationDuplicateName, + self.dbapi.update_allocation, allocation2.id, + {'name': name1}) + + def test_update_allocation_with_node_id(self): + res = self.dbapi.update_allocation(self.allocation.id, + {'name': 'newname', + 'traits': ['foo'], + 'node_id': self.node.id}) + self.assertEqual('newname', res.name) + self.assertEqual(['foo'], res.traits) + self.assertEqual(self.node.id, res.node_id) + + node = self.dbapi.get_node_by_id(self.node.id) + self.assertEqual(res.id, node.allocation_id) + self.assertEqual(res.uuid, node.instance_uuid) + self.assertEqual(['foo'], node.instance_info['traits']) + + def test_update_allocation_node_already_associated(self): + existing_uuid = uuidutils.generate_uuid() + self.dbapi.update_node(self.node.id, {'instance_uuid': existing_uuid}) + self.assertRaises(exception.NodeAssociated, + self.dbapi.update_allocation, self.allocation.id, + {'node_id': self.node.id, 'traits': ['foo']}) + + # Make sure we do not see partial updates + allocation = self.dbapi.get_allocation_by_id(self.allocation.id) + self.assertEqual([], allocation.traits) + self.assertIsNone(allocation.node_id) + + node = self.dbapi.get_node_by_id(self.node.id) + self.assertIsNone(node.allocation_id) + self.assertEqual(existing_uuid, node.instance_uuid) + self.assertNotIn('traits', node.instance_info) + + def test_update_allocation_associated_with_another_node(self): + db_utils.create_test_node(uuid=uuidutils.generate_uuid(), + allocation_id=self.allocation.id, + instance_uuid=self.allocation.uuid) + + self.assertRaises(exception.InstanceAssociated, + self.dbapi.update_allocation, self.allocation.id, + {'node_id': self.node.id, 'traits': ['foo']}) + + # Make sure we do not see partial updates + allocation = self.dbapi.get_allocation_by_id(self.allocation.id) + self.assertEqual([], allocation.traits) + self.assertIsNone(allocation.node_id) + + node = self.dbapi.get_node_by_id(self.node.id) + self.assertIsNone(node.allocation_id) + self.assertIsNone(node.instance_uuid) + self.assertNotIn('traits', node.instance_info) + + def test_create_allocation_duplicated_name(self): + self.assertRaises(exception.AllocationDuplicateName, + db_utils.create_test_allocation, + uuid=uuidutils.generate_uuid(), + name=self.allocation.name) + + def test_create_allocation_duplicated_uuid(self): + self.assertRaises(exception.AllocationAlreadyExists, + db_utils.create_test_allocation, + uuid=self.allocation.uuid) diff --git a/ironic/tests/unit/db/test_nodes.py b/ironic/tests/unit/db/test_nodes.py index 45b770370..f92baa7b1 100644 --- a/ironic/tests/unit/db/test_nodes.py +++ b/ironic/tests/unit/db/test_nodes.py @@ -302,7 +302,8 @@ class DbNodeTestCase(base.DbTestCase): maintenance=True, fault='boom', resource_class='foo', - conductor_group='group1') + conductor_group='group1', + power_state='power on') res = self.dbapi.get_node_list(filters={'chassis_uuid': ch1['uuid']}) self.assertEqual([node1.id], [r.id for r in res]) @@ -355,6 +356,18 @@ class DbNodeTestCase(base.DbTestCase): res = self.dbapi.get_node_list(filters={'uuid': node1.uuid}) self.assertEqual([node1.id], [r.id for r in res]) + uuids = [uuidutils.generate_uuid(), + node1.uuid, + uuidutils.generate_uuid()] + res = self.dbapi.get_node_list(filters={'uuid_in': uuids}) + self.assertEqual([node1.id], [r.id for r in res]) + + res = self.dbapi.get_node_list(filters={'with_power_state': True}) + self.assertEqual([node2.id], [r.id for r in res]) + + res = self.dbapi.get_node_list(filters={'with_power_state': False}) + self.assertEqual([node1.id], [r.id for r in res]) + # ensure unknown filters explode filters = {'bad_filter': 'foo'} self.assertRaisesRegex(ValueError, @@ -519,6 +532,15 @@ class DbNodeTestCase(base.DbTestCase): self.assertRaises(exception.NodeNotFound, self.dbapi.node_trait_exists, node.id, trait.trait) + def test_allocations_get_destroyed_after_destroying_a_node_by_uuid(self): + node = utils.create_test_node() + + allocation = utils.create_test_allocation(node_id=node.id) + + self.dbapi.destroy_node(node.uuid) + self.assertRaises(exception.AllocationNotFound, + self.dbapi.get_allocation_by_id, allocation.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 a0bc1fb38..f3f838000 100644 --- a/ironic/tests/unit/db/utils.py +++ b/ironic/tests/unit/db/utils.py @@ -16,10 +16,12 @@ from oslo_utils import timeutils +from oslo_utils import uuidutils from ironic.common import states from ironic.db import api as db_api from ironic.drivers import base as drivers_base +from ironic.objects import allocation from ironic.objects import bios from ironic.objects import chassis from ironic.objects import conductor @@ -219,6 +221,7 @@ def get_test_node(**kw): 'protected_reason': kw.get('protected_reason', None), 'conductor': kw.get('conductor'), 'owner': kw.get('owner', None), + 'allocation_id': kw.get('allocation_id'), } for iface in drivers_base.ALL_INTERFACES: @@ -588,3 +591,30 @@ def get_test_bios_setting_setting_list(): {'name': 'hyperthread', 'value': 'enabled'}, {'name': 'numlock', 'value': 'off'} ] + + +def get_test_allocation(**kw): + return { + 'candidate_nodes': kw.get('candidate_nodes', []), + 'conductor_affinity': kw.get('conductor_affinity'), + 'created_at': kw.get('created_at'), + 'extra': kw.get('extra', {}), + 'id': kw.get('id', 42), + 'last_error': kw.get('last_error'), + 'name': kw.get('name'), + 'node_id': kw.get('node_id'), + 'resource_class': kw.get('resource_class', 'baremetal'), + 'state': kw.get('state', 'allocating'), + 'traits': kw.get('traits', []), + 'updated_at': kw.get('updated_at'), + 'uuid': kw.get('uuid', uuidutils.generate_uuid()), + 'version': kw.get('version', allocation.Allocation.VERSION), + } + + +def create_test_allocation(**kw): + allocation = get_test_allocation(**kw) + if 'id' not in kw: + del allocation['id'] + dbapi = db_api.get_instance() + return dbapi.create_allocation(allocation) diff --git a/ironic/tests/unit/objects/test_allocation.py b/ironic/tests/unit/objects/test_allocation.py new file mode 100644 index 000000000..baa7d8d71 --- /dev/null +++ b/ironic/tests/unit/objects/test_allocation.py @@ -0,0 +1,144 @@ +# 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 datetime + +import mock +from testtools import matchers + +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 TestAllocationObject(db_base.DbTestCase, obj_utils.SchemasTestMixIn): + + def setUp(self): + super(TestAllocationObject, self).setUp() + self.fake_allocation = db_utils.get_test_allocation(name='host1') + + def test_get_by_id(self): + allocation_id = self.fake_allocation['id'] + with mock.patch.object(self.dbapi, 'get_allocation_by_id', + autospec=True) as mock_get_allocation: + mock_get_allocation.return_value = self.fake_allocation + + allocation = objects.Allocation.get(self.context, allocation_id) + + mock_get_allocation.assert_called_once_with(allocation_id) + self.assertEqual(self.context, allocation._context) + + def test_get_by_uuid(self): + uuid = self.fake_allocation['uuid'] + with mock.patch.object(self.dbapi, 'get_allocation_by_uuid', + autospec=True) as mock_get_allocation: + mock_get_allocation.return_value = self.fake_allocation + + allocation = objects.Allocation.get(self.context, uuid) + + mock_get_allocation.assert_called_once_with(uuid) + self.assertEqual(self.context, allocation._context) + + def test_get_by_name(self): + name = self.fake_allocation['name'] + with mock.patch.object(self.dbapi, 'get_allocation_by_name', + autospec=True) as mock_get_allocation: + mock_get_allocation.return_value = self.fake_allocation + allocation = objects.Allocation.get(self.context, name) + + mock_get_allocation.assert_called_once_with(name) + self.assertEqual(self.context, allocation._context) + + def test_get_bad_id_and_uuid_and_name(self): + self.assertRaises(exception.InvalidIdentity, + objects.Allocation.get, + self.context, + 'not:a_name_or_uuid') + + def test_create(self): + allocation = objects.Allocation(self.context, **self.fake_allocation) + with mock.patch.object(self.dbapi, 'create_allocation', + autospec=True) as mock_create_allocation: + mock_create_allocation.return_value = ( + db_utils.get_test_allocation()) + + allocation.create() + + args, _kwargs = mock_create_allocation.call_args + self.assertEqual(objects.Allocation.VERSION, args[0]['version']) + + def test_save(self): + uuid = self.fake_allocation['uuid'] + test_time = datetime.datetime(2000, 1, 1, 0, 0) + with mock.patch.object(self.dbapi, 'get_allocation_by_uuid', + autospec=True) as mock_get_allocation: + mock_get_allocation.return_value = self.fake_allocation + with mock.patch.object(self.dbapi, 'update_allocation', + autospec=True) as mock_update_allocation: + mock_update_allocation.return_value = ( + db_utils.get_test_allocation(name='newname', + updated_at=test_time)) + p = objects.Allocation.get_by_uuid(self.context, uuid) + p.name = 'newname' + p.save() + + mock_get_allocation.assert_called_once_with(uuid) + mock_update_allocation.assert_called_once_with( + uuid, {'version': objects.Allocation.VERSION, + 'name': 'newname'}) + self.assertEqual(self.context, p._context) + res_updated_at = (p.updated_at).replace(tzinfo=None) + self.assertEqual(test_time, res_updated_at) + + def test_refresh(self): + uuid = self.fake_allocation['uuid'] + returns = [self.fake_allocation, + db_utils.get_test_allocation(name='newname')] + expected = [mock.call(uuid), mock.call(uuid)] + with mock.patch.object(self.dbapi, 'get_allocation_by_uuid', + side_effect=returns, + autospec=True) as mock_get_allocation: + p = objects.Allocation.get_by_uuid(self.context, uuid) + self.assertEqual(self.fake_allocation['name'], p.name) + p.refresh() + self.assertEqual('newname', p.name) + + self.assertEqual(expected, mock_get_allocation.call_args_list) + self.assertEqual(self.context, p._context) + + def test_save_after_refresh(self): + # Ensure that it's possible to do object.save() after object.refresh() + db_allocation = db_utils.create_test_allocation() + p = objects.Allocation.get_by_uuid(self.context, db_allocation.uuid) + p_copy = objects.Allocation.get_by_uuid(self.context, + db_allocation.uuid) + p.name = 'newname' + p.save() + p_copy.refresh() + p.copy = 'newname2' + # Ensure this passes and an exception is not generated + p_copy.save() + + def test_list(self): + with mock.patch.object(self.dbapi, 'get_allocation_list', + autospec=True) as mock_get_list: + mock_get_list.return_value = [self.fake_allocation] + allocations = objects.Allocation.list(self.context) + self.assertThat(allocations, matchers.HasLength(1)) + self.assertIsInstance(allocations[0], objects.Allocation) + self.assertEqual(self.context, allocations[0]._context) + + def test_payload_schemas(self): + self._check_payload_schemas(objects.allocation, + objects.Allocation.fields) diff --git a/ironic/tests/unit/objects/test_node.py b/ironic/tests/unit/objects/test_node.py index 536e86ac0..6d25cad24 100644 --- a/ironic/tests/unit/objects/test_node.py +++ b/ironic/tests/unit/objects/test_node.py @@ -888,6 +888,67 @@ class TestConvertToVersion(db_base.DbTestCase): self.assertIsNone(node.owner) self.assertEqual({}, node.obj_get_changes()) + def test_allocation_id_supported_missing(self): + # allocation_id_interface not set, should be set to default. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + delattr(node, 'allocation_id') + node.obj_reset_changes() + node._convert_to_version("1.31") + self.assertIsNone(node.allocation_id) + self.assertEqual({'allocation_id': None}, + node.obj_get_changes()) + + def test_allocation_id_supported_set(self): + # allocation_id set, no change required. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.allocation_id = 42 + node.obj_reset_changes() + node._convert_to_version("1.31") + self.assertEqual(42, node.allocation_id) + self.assertEqual({}, node.obj_get_changes()) + + def test_allocation_id_unsupported_missing(self): + # allocation_id not set, no change required. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + delattr(node, 'allocation_id') + node.obj_reset_changes() + node._convert_to_version("1.30") + self.assertNotIn('allocation_id', node) + self.assertEqual({}, node.obj_get_changes()) + + def test_allocation_id_unsupported_set_remove(self): + # allocation_id set, should be removed. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.allocation_id = 42 + node.obj_reset_changes() + node._convert_to_version("1.30") + self.assertNotIn('allocation_id', node) + self.assertEqual({}, node.obj_get_changes()) + + def test_allocation_id_unsupported_set_no_remove_non_default(self): + # allocation_id set, should be set to default. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.allocation_id = 42 + node.obj_reset_changes() + node._convert_to_version("1.30", False) + self.assertIsNone(node.allocation_id) + self.assertEqual({'allocation_id': None}, + node.obj_get_changes()) + + def test_allocation_id_unsupported_set_no_remove_default(self): + # allocation_id set, no change required. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.allocation_id = None + node.obj_reset_changes() + node._convert_to_version("1.30", False) + self.assertIsNone(node.allocation_id) + self.assertEqual({}, node.obj_get_changes()) + class TestNodePayloads(db_base.DbTestCase): diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index 4f409053c..bd8808506 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -677,7 +677,7 @@ class TestObject(_LocalTest, _TestObject): # version bump. It is an MD5 hash of the object fields and remotable methods. # The fingerprint values should only be changed if there is a version bump. expected_object_fingerprints = { - 'Node': '1.30-8313460d6ea5457a527cd3d85e5ee3d8', + 'Node': '1.31-1b77c11e94f971a71c76f5f44fb5b3f4', 'MyObj': '1.5-9459d30d6954bffc7a9afd347a807ca6', 'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905', 'Port': '1.8-898a47921f4a1f53fcdddd4eeb179e0b', @@ -714,6 +714,9 @@ expected_object_fingerprints = { 'TraitList': '1.0-33a2e1bb91ad4082f9f63429b77c1244', 'BIOSSetting': '1.0-fd4a791dc2139a7cc21cefbbaedfd9e7', 'BIOSSettingList': '1.0-33a2e1bb91ad4082f9f63429b77c1244', + 'Allocation': '1.0-25ebf609743cd3f332a4f80fcb818102', + 'AllocationCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', + 'AllocationCRUDPayload': '1.0-a82389d019f37cfe54b50049f73911b3', } |