diff options
author | Dmitry Tantsur <divius.inside@gmail.com> | 2018-12-10 16:50:42 +0100 |
---|---|---|
committer | Dmitry Tantsur <divius.inside@gmail.com> | 2019-01-07 12:51:10 +0100 |
commit | a4717d9958c5b434ab01afaca095e018db5aaa7d (patch) | |
tree | d6c1d4bb5019549570c344d787e2083ba74f9a44 | |
parent | c10ee94b92174ce78073afc2eacb300fa649736f (diff) | |
download | ironic-a4717d9958c5b434ab01afaca095e018db5aaa7d.tar.gz |
Allocation API: database and RPC
This change adds the database models and API, as well as RPC objects
for the allocation API. Also the node database API is extended with
query by power state and list of UUIDs.
There is one discrepancy from the initially approved spec: since we
do not have to separately update traits in an allocation, the planned
allocation_traits table was replaced by a simple field.
Change-Id: I6af132e2bfa6e4f7b93bd20f22a668790a22a30e
Story: #2004341
Task: #28367
-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', } |