diff --git a/ironic/cmd/dbsync.py b/ironic/cmd/dbsync.py index f554cbdfc1..076fda2ee2 100644 --- a/ironic/cmd/dbsync.py +++ b/ironic/cmd/dbsync.py @@ -83,6 +83,9 @@ ONLINE_MIGRATIONS = ( NEW_MODELS = [ # TODO(dtantsur): remove in Train 'Allocation', + # TODO(mgoddard): remove in Train + 'DeployTemplate', + 'DeployTemplateStep', ] diff --git a/ironic/common/exception.py b/ironic/common/exception.py index 14cfe094fa..e5630990c5 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -807,3 +807,15 @@ class AllocationAlreadyExists(Conflict): class AllocationFailed(IronicException): _msg_fmt = _("Failed to process allocation %(uuid)s: %(error)s.") + + +class DeployTemplateDuplicateName(Conflict): + _msg_fmt = _("A deploy template with name %(name)s already exists.") + + +class DeployTemplateAlreadyExists(Conflict): + _msg_fmt = _("A deploy template with UUID %(uuid)s already exists.") + + +class DeployTemplateNotFound(NotFound): + _msg_fmt = _("Deploy template %(template)s could not be found.") diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index 3688be00ca..7664870181 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -138,6 +138,7 @@ RELEASE_MAPPING = { 'Node': ['1.32', '1.31', '1.30', '1.29', '1.28'], 'Conductor': ['1.3'], 'Chassis': ['1.3'], + 'DeployTemplate': ['1.0'], 'Port': ['1.9'], 'Portgroup': ['1.4'], 'Trait': ['1.0'], diff --git a/ironic/db/api.py b/ironic/db/api.py index c21d7454d6..135b762e4d 100644 --- a/ironic/db/api.py +++ b/ironic/db/api.py @@ -1165,3 +1165,99 @@ class Connection(object): :param allocation_id: Allocation ID :raises: AllocationNotFound """ + + @abc.abstractmethod + def create_deploy_template(self, values, version): + """Create a deployment template. + + :param values: A dict describing the deployment template. For example: + + :: + + { + 'uuid': uuidutils.generate_uuid(), + 'name': 'CUSTOM_DT1', + } + :param version: the version of the object.DeployTemplate. + :raises: DeployTemplateDuplicateName if a deploy template with the same + name exists. + :raises: DeployTemplateAlreadyExists if a deploy template with the same + UUID exists. + :returns: A deploy template. + """ + + @abc.abstractmethod + def update_deploy_template(self, template_id, values): + """Update a deployment template. + + :param template_id: ID of the deployment template to update. + :param values: A dict describing the deployment template. For example: + + :: + + { + 'uuid': uuidutils.generate_uuid(), + 'name': 'CUSTOM_DT1', + } + :raises: DeployTemplateDuplicateName if a deploy template with the same + name exists. + :raises: DeployTemplateNotFound if the deploy template does not exist. + :returns: A deploy template. + """ + + @abc.abstractmethod + def destroy_deploy_template(self, template_id): + """Destroy a deployment template. + + :param template_id: ID of the deployment template to destroy. + :raises: DeployTemplateNotFound if the deploy template does not exist. + """ + + @abc.abstractmethod + def get_deploy_template_by_id(self, template_id): + """Retrieve a deployment template by ID. + + :param template_id: ID of the deployment template to retrieve. + :raises: DeployTemplateNotFound if the deploy template does not exist. + :returns: A deploy template. + """ + + @abc.abstractmethod + def get_deploy_template_by_uuid(self, template_uuid): + """Retrieve a deployment template by UUID. + + :param template_uuid: UUID of the deployment template to retrieve. + :raises: DeployTemplateNotFound if the deploy template does not exist. + :returns: A deploy template. + """ + + @abc.abstractmethod + def get_deploy_template_by_name(self, template_name): + """Retrieve a deployment template by name. + + :param template_name: name of the deployment template to retrieve. + :raises: DeployTemplateNotFound if the deploy template does not exist. + :returns: A deploy template. + """ + + @abc.abstractmethod + def get_deploy_template_list(self, limit=None, marker=None, + sort_key=None, sort_dir=None): + """Retrieve a list of deployment templates. + + :param limit: Maximum number of deploy templates 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 deploy templates. + """ + + @abc.abstractmethod + def get_deploy_template_list_by_names(self, names): + """Return a list of deployment templates with one of a list of names. + + :param names: List of names to filter by. + :returns: A list of deploy templates. + """ diff --git a/ironic/db/sqlalchemy/alembic/versions/2aac7e0872f6_add_deploy_templates.py b/ironic/db/sqlalchemy/alembic/versions/2aac7e0872f6_add_deploy_templates.py new file mode 100644 index 0000000000..0b5e8ff10f --- /dev/null +++ b/ironic/db/sqlalchemy/alembic/versions/2aac7e0872f6_add_deploy_templates.py @@ -0,0 +1,67 @@ +# 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. + +"""Create deploy_templates and deploy_template_steps tables. + +Revision ID: 2aac7e0872f6 +Revises: 28c44432c9c3 +Create Date: 2018-12-27 11:49:15.029650 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '2aac7e0872f6' +down_revision = '28c44432c9c3' + + +def upgrade(): + op.create_table( + 'deploy_templates', + sa.Column('version', sa.String(length=15), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False, + autoincrement=True), + sa.Column('uuid', sa.String(length=36)), + sa.Column('name', sa.String(length=255), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('uuid', name='uniq_deploytemplates0uuid'), + sa.UniqueConstraint('name', name='uniq_deploytemplates0name'), + mysql_ENGINE='InnoDB', + mysql_DEFAULT_CHARSET='UTF8' + ) + + op.create_table( + 'deploy_template_steps', + sa.Column('version', sa.String(length=15), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False, + autoincrement=True), + sa.Column('deploy_template_id', sa.Integer(), nullable=False, + autoincrement=False), + sa.Column('interface', sa.String(length=255), nullable=False), + sa.Column('step', sa.String(length=255), nullable=False), + sa.Column('args', sa.Text, nullable=False), + sa.Column('priority', sa.Integer, nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['deploy_template_id'], + ['deploy_templates.id']), + sa.Index('deploy_template_id', 'deploy_template_id'), + sa.Index('deploy_template_steps_interface_idx', 'interface'), + sa.Index('deploy_template_steps_step_idx', 'step'), + mysql_ENGINE='InnoDB', + mysql_DEFAULT_CHARSET='UTF8' + ) diff --git a/ironic/db/sqlalchemy/api.py b/ironic/db/sqlalchemy/api.py index b134325f50..ec7e9dd673 100644 --- a/ironic/db/sqlalchemy/api.py +++ b/ironic/db/sqlalchemy/api.py @@ -85,6 +85,14 @@ def _get_node_query_with_all(): .options(joinedload('traits'))) +def _get_deploy_template_query_with_steps(): + """Return a query object for the DeployTemplate joined with steps. + + :returns: a query object. + """ + return model_query(models.DeployTemplate).options(joinedload('steps')) + + def model_query(model, *args, **kwargs): """Query helper for simpler session usage. @@ -218,6 +226,42 @@ def _filter_active_conductors(query, interval=None): return query +def _zip_matching(a, b, key): + """Zip two unsorted lists, yielding matching items or None. + + Each zipped item is a tuple taking one of three forms: + + (a[i], b[j]) if a[i] and b[j] are equal. + (a[i], None) if a[i] is less than b[j] or b is empty. + (None, b[j]) if a[i] is greater than b[j] or a is empty. + + Note that the returned list may be longer than either of the two + lists. + + Adapted from https://stackoverflow.com/a/11426702. + + :param a: the first list. + :param b: the second list. + :param key: a function that generates a key used to compare items. + """ + a = collections.deque(sorted(a, key=key)) + b = collections.deque(sorted(b, key=key)) + while a and b: + k_a = key(a[0]) + k_b = key(b[0]) + if k_a == k_b: + yield a.popleft(), b.popleft() + elif k_a < k_b: + yield a.popleft(), None + else: + yield None, b.popleft() + # Consume any remaining items in each deque. + for i in a: + yield i, None + for i in b: + yield None, i + + @profiler.trace_cls("db_api") class Connection(api.Connection): """SqlAlchemy connection.""" @@ -1710,3 +1754,155 @@ class Connection(api.Connection): node_query.update({'allocation_id': None, 'instance_uuid': None}) query.delete() + + @staticmethod + def _get_deploy_template_steps(steps, deploy_template_id=None): + results = [] + for values in steps: + step = models.DeployTemplateStep() + step.update(values) + if deploy_template_id: + step['deploy_template_id'] = deploy_template_id + results.append(step) + return results + + @oslo_db_api.retry_on_deadlock + def create_deploy_template(self, values, version): + steps = values.get('steps', []) + values['steps'] = self._get_deploy_template_steps(steps) + + template = models.DeployTemplate() + template.update(values) + with _session_for_write() as session: + try: + session.add(template) + session.flush() + except db_exc.DBDuplicateEntry as e: + if 'name' in e.columns: + raise exception.DeployTemplateDuplicateName( + name=values['name']) + raise exception.DeployTemplateAlreadyExists( + uuid=values['uuid']) + return template + + def _update_deploy_template_steps(self, session, template_id, steps): + """Update the steps for a deploy template. + + :param session: DB session object. + :param template_id: deploy template ID. + :param steps: list of steps that should exist for the deploy template. + """ + + def _step_key(step): + """Compare two deploy template steps.""" + return step.interface, step.step, step.args, step.priority + + # List all existing steps for the template. + query = (model_query(models.DeployTemplateStep) + .filter_by(deploy_template_id=template_id)) + current_steps = query.all() + + # List the new steps for the template. + new_steps = self._get_deploy_template_steps(steps, template_id) + + # The following is an efficient way to ensure that the steps in the + # database match those that have been requested. We compare the current + # and requested steps in a single pass using the _zip_matching + # function. + steps_to_create = [] + step_ids_to_delete = [] + for current_step, new_step in _zip_matching(current_steps, new_steps, + _step_key): + if current_step is None: + # No matching current step found for this new step - create. + steps_to_create.append(new_step) + elif new_step is None: + # No matching new step found for this current step - delete. + step_ids_to_delete.append(current_step.id) + # else: steps match, no work required. + + # Delete and create steps in bulk as necessary. + if step_ids_to_delete: + ((model_query(models.DeployTemplateStep) + .filter(models.DeployTemplateStep.id.in_(step_ids_to_delete))) + .delete(synchronize_session=False)) + if steps_to_create: + session.bulk_save_objects(steps_to_create) + + @oslo_db_api.retry_on_deadlock + def update_deploy_template(self, template_id, values): + if 'uuid' in values: + msg = _("Cannot overwrite UUID for an existing deploy template.") + raise exception.InvalidParameterValue(err=msg) + + try: + with _session_for_write() as session: + # NOTE(mgoddard): Don't issue a joined query for the update as + # this does not work with PostgreSQL. + query = model_query(models.DeployTemplate) + query = add_identity_filter(query, template_id) + try: + ref = query.with_lockmode('update').one() + except NoResultFound: + raise exception.DeployTemplateNotFound( + template=template_id) + + # First, update non-step columns. + steps = None + if 'steps' in values: + steps = values.pop('steps') + + ref.update(values) + + # If necessary, update steps. + if steps is not None: + self._update_deploy_template_steps(session, ref.id, steps) + + # Return the updated template joined with all relevant fields. + query = _get_deploy_template_query_with_steps() + query = add_identity_filter(query, template_id) + return query.one() + except db_exc.DBDuplicateEntry as e: + if 'name' in e.columns: + raise exception.DeployTemplateDuplicateName( + name=values['name']) + raise + + @oslo_db_api.retry_on_deadlock + def destroy_deploy_template(self, template_id): + with _session_for_write(): + model_query(models.DeployTemplateStep).filter_by( + deploy_template_id=template_id).delete() + count = model_query(models.DeployTemplate).filter_by( + id=template_id).delete() + if count == 0: + raise exception.DeployTemplateNotFound(template=template_id) + + def _get_deploy_template(self, field, value): + """Helper method for retrieving a deploy template.""" + query = (_get_deploy_template_query_with_steps() + .filter_by(**{field: value})) + try: + return query.one() + except NoResultFound: + raise exception.DeployTemplateNotFound(template=value) + + def get_deploy_template_by_id(self, template_id): + return self._get_deploy_template('id', template_id) + + def get_deploy_template_by_uuid(self, template_uuid): + return self._get_deploy_template('uuid', template_uuid) + + def get_deploy_template_by_name(self, template_name): + return self._get_deploy_template('name', template_name) + + def get_deploy_template_list(self, limit=None, marker=None, + sort_key=None, sort_dir=None): + query = _get_deploy_template_query_with_steps() + return _paginate_query(models.DeployTemplate, limit, marker, + sort_key, sort_dir, query) + + def get_deploy_template_list_by_names(self, names): + query = (_get_deploy_template_query_with_steps() + .filter(models.DeployTemplate.name.in_(names))) + return query.all() diff --git a/ironic/db/sqlalchemy/models.py b/ironic/db/sqlalchemy/models.py index e70fefcc66..9d90d9aa35 100644 --- a/ironic/db/sqlalchemy/models.py +++ b/ironic/db/sqlalchemy/models.py @@ -350,6 +350,45 @@ class Allocation(Base): nullable=True) +class DeployTemplate(Base): + """Represents a deployment template.""" + + __tablename__ = 'deploy_templates' + __table_args__ = ( + schema.UniqueConstraint('uuid', name='uniq_deploytemplates0uuid'), + schema.UniqueConstraint('name', name='uniq_deploytemplates0name'), + table_args()) + id = Column(Integer, primary_key=True) + uuid = Column(String(36)) + name = Column(String(255), nullable=False) + + +class DeployTemplateStep(Base): + """Represents a deployment step in a deployment template.""" + + __tablename__ = 'deploy_template_steps' + __table_args__ = ( + Index('deploy_template_id', 'deploy_template_id'), + Index('deploy_template_steps_interface_idx', 'interface'), + Index('deploy_template_steps_step_idx', 'step'), + table_args()) + id = Column(Integer, primary_key=True) + deploy_template_id = Column(Integer, ForeignKey('deploy_templates.id'), + nullable=False) + interface = Column(String(255), nullable=False) + step = Column(String(255), nullable=False) + args = Column(db_types.JsonEncodedDict, nullable=False) + priority = Column(Integer, nullable=False) + deploy_template = orm.relationship( + "DeployTemplate", + backref='steps', + primaryjoin=( + 'and_(DeployTemplateStep.deploy_template_id == ' + 'DeployTemplate.id)'), + foreign_keys=deploy_template_id + ) + + 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 96ddc1c28e..2afe75003b 100644 --- a/ironic/objects/__init__.py +++ b/ironic/objects/__init__.py @@ -28,6 +28,7 @@ def register_all(): __import__('ironic.objects.bios') __import__('ironic.objects.chassis') __import__('ironic.objects.conductor') + __import__('ironic.objects.deploy_template') __import__('ironic.objects.node') __import__('ironic.objects.port') __import__('ironic.objects.portgroup') diff --git a/ironic/objects/deploy_template.py b/ironic/objects/deploy_template.py new file mode 100644 index 0000000000..f6358fae52 --- /dev/null +++ b/ironic/objects/deploy_template.py @@ -0,0 +1,240 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_versionedobjects import base as object_base + +from ironic.db import api as db_api +from ironic.objects import base +from ironic.objects import fields as object_fields + + +@base.IronicObjectRegistry.register +class DeployTemplate(base.IronicObject, object_base.VersionedObjectDictCompat): + # Version 1.0: Initial version + VERSION = '1.0' + + dbapi = db_api.get_instance() + + fields = { + 'id': object_fields.IntegerField(), + 'uuid': object_fields.UUIDField(nullable=False), + 'name': object_fields.StringField(nullable=False), + 'steps': object_fields.ListOfFlexibleDictsField(nullable=False), + } + + # NOTE(mgoddard): We don't want to enable RPC on this call just yet. + # Remotable methods can be used in the future to replace current explicit + # RPC calls. Implications of calling new remote procedures should be + # thought through. + # @object_base.remotable + def create(self, context=None): + """Create a DeployTemplate 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.: DeployTemplate(context). + :raises: DeployTemplateDuplicateName if a deploy template with the same + name exists. + :raises: DeployTemplateAlreadyExists if a deploy template with the same + UUID exists. + """ + values = self.do_version_changes_for_db() + db_template = self.dbapi.create_deploy_template( + values, values['version']) + self._from_db_object(self._context, self, db_template) + + # NOTE(mgoddard): We don't want to enable RPC on this call just yet. + # Remotable methods can be used in the future to replace current explicit + # RPC calls. Implications of calling new remote procedures should be + # thought through. + # @object_base.remotable + def save(self, context=None): + """Save updates to this DeployTemplate. + + Column-wise updates will be made based on the result of + self.what_changed(). + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + 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.: DeployTemplate(context) + :raises: DeployTemplateDuplicateName if a deploy template with the same + name exists. + :raises: DeployTemplateNotFound if the deploy template does not exist. + """ + updates = self.do_version_changes_for_db() + db_template = self.dbapi.update_deploy_template(self.uuid, updates) + self._from_db_object(self._context, self, db_template) + + # NOTE(mgoddard): We don't want to enable RPC on this call just yet. + # Remotable methods can be used in the future to replace current explicit + # RPC calls. Implications of calling new remote procedures should be + # thought through. + # @object_base.remotable_classmethod + def destroy(self): + """Delete the DeployTemplate 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.: DeployTemplate(context). + :raises: DeployTemplateNotFound if the deploy template no longer + appears in the database. + """ + self.dbapi.destroy_deploy_template(self.id) + self.obj_reset_changes() + + # NOTE(mgoddard): We don't want to enable RPC on this call just yet. + # Remotable methods can be used in the future to replace current explicit + # RPC calls. Implications of calling new remote procedures should be + # thought through. + # @object_base.remotable_classmethod + @classmethod + def get_by_id(cls, context, template_id): + """Find a deploy template based on its integer ID. + + :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.: DeployTemplate(context). + :param template_id: The ID of a deploy template. + :raises: DeployTemplateNotFound if the deploy template no longer + appears in the database. + :returns: a :class:`DeployTemplate` object. + """ + db_template = cls.dbapi.get_deploy_template_by_id(template_id) + template = cls._from_db_object(context, cls(), db_template) + return template + + # NOTE(mgoddard): We don't want to enable RPC on this call just yet. + # Remotable methods can be used in the future to replace current explicit + # RPC calls. Implications of calling new remote procedures should be + # thought through. + # @object_base.remotable_classmethod + @classmethod + def get_by_uuid(cls, context, uuid): + """Find a deploy template based on its UUID. + + :param context: security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: DeployTemplate(context). + :param uuid: The UUID of a deploy template. + :raises: DeployTemplateNotFound if the deploy template no longer + appears in the database. + :returns: a :class:`DeployTemplate` object. + """ + db_template = cls.dbapi.get_deploy_template_by_uuid(uuid) + template = cls._from_db_object(context, cls(), db_template) + return template + + # NOTE(mgoddard): We don't want to enable RPC on this call just yet. + # Remotable methods can be used in the future to replace current explicit + # RPC calls. Implications of calling new remote procedures should be + # thought through. + # @object_base.remotable_classmethod + @classmethod + def get_by_name(cls, context, name): + """Find a deploy template based on its name. + + :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.: DeployTemplate(context). + :param name: The name of a deploy template. + :raises: DeployTemplateNotFound if the deploy template no longer + appears in the database. + :returns: a :class:`DeployTemplate` object. + """ + db_template = cls.dbapi.get_deploy_template_by_name(name) + template = cls._from_db_object(context, cls(), db_template) + return template + + # NOTE(mgoddard): We don't want to enable RPC on this call just yet. + # Remotable methods can be used in the future to replace current explicit + # RPC calls. Implications of calling new remote procedures should be + # thought through. + # @object_base.remotable_classmethod + @classmethod + def list(cls, context, limit=None, marker=None, sort_key=None, + sort_dir=None): + """Return a list of DeployTemplate objects. + + :param context: security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: DeployTemplate(context). + :param limit: maximum number of resources to return in a single result. + :param marker: pagination marker for large data sets. + :param sort_key: column to sort results by. + :param sort_dir: direction to sort. "asc" or "desc". + :returns: a list of :class:`DeployTemplate` objects. + """ + db_templates = cls.dbapi.get_deploy_template_list( + limit=limit, marker=marker, sort_key=sort_key, sort_dir=sort_dir) + return cls._from_db_object_list(context, db_templates) + + # NOTE(mgoddard): We don't want to enable RPC on this call just yet. + # Remotable methods can be used in the future to replace current explicit + # RPC calls. Implications of calling new remote procedures should be + # thought through. + # @object_base.remotable_classmethod + @classmethod + def list_by_names(cls, context, names): + """Return a list of DeployTemplate objects matching a set of names. + + :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.: DeployTemplate(context). + :param names: a list of names to filter by. + :returns: a list of :class:`DeployTemplate` objects. + """ + db_templates = cls.dbapi.get_deploy_template_list_by_names(names) + return cls._from_db_object_list(context, db_templates) + + def refresh(self, context=None): + """Loads updates for this deploy template. + + Loads a deploy template with the same uuid from the database and + checks for updated attributes. Updates are applied from + the loaded template column by column, if there are any updates. + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Port(context) + :raises: DeployTemplateNotFound if the deploy template no longer + appears in the database. + """ + current = self.get_by_uuid(self._context, uuid=self.uuid) + self.obj_refresh(current) + self.obj_reset_changes() diff --git a/ironic/objects/fields.py b/ironic/objects/fields.py index b197e36ec2..fad3ac74df 100644 --- a/ironic/objects/fields.py +++ b/ironic/objects/fields.py @@ -106,6 +106,10 @@ class FlexibleDictField(object_fields.AutoTypedField): super(FlexibleDictField, self)._null(obj, attr) +class ListOfFlexibleDictsField(object_fields.AutoTypedField): + AUTO_TYPE = object_fields.List(FlexibleDict()) + + class EnumField(object_fields.EnumField): pass diff --git a/ironic/tests/unit/common/test_release_mappings.py b/ironic/tests/unit/common/test_release_mappings.py index 4fcc0b7f79..19b52fe230 100644 --- a/ironic/tests/unit/common/test_release_mappings.py +++ b/ironic/tests/unit/common/test_release_mappings.py @@ -85,7 +85,7 @@ class ReleaseMappingsTestCase(base.TestCase): self.assertIn('master', release_mappings.RELEASE_MAPPING) model_names = set((s.__name__ for s in models.Base.__subclasses__())) exceptions = set(['NodeTag', 'ConductorHardwareInterfaces', - 'NodeTrait', 'BIOSSetting']) + 'NodeTrait', 'BIOSSetting', 'DeployTemplateStep']) # NOTE(xek): As a rule, all models which can be changed between # releases or are sent through RPC should have their counterpart # versioned objects. diff --git a/ironic/tests/unit/db/sqlalchemy/test_migrations.py b/ironic/tests/unit/db/sqlalchemy/test_migrations.py index 765ab4273f..f41eb25278 100644 --- a/ironic/tests/unit/db/sqlalchemy/test_migrations.py +++ b/ironic/tests/unit/db/sqlalchemy/test_migrations.py @@ -858,6 +858,102 @@ class MigrationCheckersMixin(object): self.assertIsInstance(nodes_tbl.c.description.type, sqlalchemy.types.TEXT) + def _check_2aac7e0872f6(self, engine, data): + # Deploy templates. + deploy_templates = db_utils.get_table(engine, 'deploy_templates') + col_names = [column.name for column in deploy_templates.c] + expected = ['created_at', 'updated_at', 'version', + 'id', 'uuid', 'name'] + self.assertEqual(sorted(expected), sorted(col_names)) + self.assertIsInstance(deploy_templates.c.created_at.type, + sqlalchemy.types.DateTime) + self.assertIsInstance(deploy_templates.c.updated_at.type, + sqlalchemy.types.DateTime) + self.assertIsInstance(deploy_templates.c.version.type, + sqlalchemy.types.String) + self.assertIsInstance(deploy_templates.c.id.type, + sqlalchemy.types.Integer) + self.assertIsInstance(deploy_templates.c.uuid.type, + sqlalchemy.types.String) + self.assertIsInstance(deploy_templates.c.name.type, + sqlalchemy.types.String) + + # Insert a deploy template. + uuid = uuidutils.generate_uuid() + name = 'CUSTOM_DT1' + template = {'name': name, 'uuid': uuid} + deploy_templates.insert().execute(template) + # Query by UUID. + result = deploy_templates.select( + deploy_templates.c.uuid == uuid).execute().first() + template_id = result['id'] + self.assertEqual(name, result['name']) + # Query by name. + result = deploy_templates.select( + deploy_templates.c.name == name).execute().first() + self.assertEqual(template_id, result['id']) + # Query by ID. + result = deploy_templates.select( + deploy_templates.c.id == template_id).execute().first() + self.assertEqual(uuid, result['uuid']) + self.assertEqual(name, result['name']) + # UUID is unique. + template = {'name': 'CUSTOM_DT2', 'uuid': uuid} + self.assertRaises(db_exc.DBDuplicateEntry, + deploy_templates.insert().execute, template) + # Name is unique. + template = {'name': name, 'uuid': uuidutils.generate_uuid()} + self.assertRaises(db_exc.DBDuplicateEntry, + deploy_templates.insert().execute, template) + + # Deploy template steps. + deploy_template_steps = db_utils.get_table(engine, + 'deploy_template_steps') + col_names = [column.name for column in deploy_template_steps.c] + expected = ['created_at', 'updated_at', 'version', + 'id', 'deploy_template_id', 'interface', 'step', 'args', + 'priority'] + self.assertEqual(sorted(expected), sorted(col_names)) + + self.assertIsInstance(deploy_template_steps.c.created_at.type, + sqlalchemy.types.DateTime) + self.assertIsInstance(deploy_template_steps.c.updated_at.type, + sqlalchemy.types.DateTime) + self.assertIsInstance(deploy_template_steps.c.version.type, + sqlalchemy.types.String) + self.assertIsInstance(deploy_template_steps.c.id.type, + sqlalchemy.types.Integer) + self.assertIsInstance(deploy_template_steps.c.deploy_template_id.type, + sqlalchemy.types.Integer) + self.assertIsInstance(deploy_template_steps.c.interface.type, + sqlalchemy.types.String) + self.assertIsInstance(deploy_template_steps.c.step.type, + sqlalchemy.types.String) + self.assertIsInstance(deploy_template_steps.c.args.type, + sqlalchemy.types.Text) + self.assertIsInstance(deploy_template_steps.c.priority.type, + sqlalchemy.types.Integer) + + # Insert a deploy template step. + interface = 'raid' + step_name = 'create_configuration' + args = '{"logical_disks": []}' + priority = 10 + step = {'deploy_template_id': template_id, 'interface': interface, + 'step': step_name, 'args': args, 'priority': priority} + deploy_template_steps.insert().execute(step) + # Query by deploy template ID. + result = deploy_template_steps.select( + deploy_template_steps.c.deploy_template_id == + template_id).execute().first() + self.assertEqual(template_id, result['deploy_template_id']) + self.assertEqual(interface, result['interface']) + self.assertEqual(step_name, result['step']) + self.assertEqual(args, result['args']) + self.assertEqual(priority, result['priority']) + # Insert another step for the same template. + deploy_template_steps.insert().execute(step) + 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_deploy_templates.py b/ironic/tests/unit/db/test_deploy_templates.py new file mode 100644 index 0000000000..59fdb447a2 --- /dev/null +++ b/ironic/tests/unit/db/test_deploy_templates.py @@ -0,0 +1,194 @@ +# 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 DeployTemplates via the DB API""" + +from oslo_db import exception as db_exc +from oslo_utils import uuidutils +import six + +from ironic.common import exception +from ironic.tests.unit.db import base +from ironic.tests.unit.db import utils as db_utils + + +class DbDeployTemplateTestCase(base.DbTestCase): + + def setUp(self): + super(DbDeployTemplateTestCase, self).setUp() + self.template = db_utils.create_test_deploy_template() + + def test_create(self): + self.assertEqual('CUSTOM_DT1', self.template.name) + self.assertEqual(1, len(self.template.steps)) + step = self.template.steps[0] + self.assertEqual(self.template.id, step.deploy_template_id) + self.assertEqual('raid', step.interface) + self.assertEqual('create_configuration', step.step) + self.assertEqual({'logical_disks': []}, step.args) + self.assertEqual(10, step.priority) + + def test_create_no_steps(self): + uuid = uuidutils.generate_uuid() + template = db_utils.create_test_deploy_template( + uuid=uuid, name='CUSTOM_DT2', steps=[]) + self.assertEqual([], template.steps) + + def test_create_duplicate_uuid(self): + self.assertRaises(exception.DeployTemplateAlreadyExists, + db_utils.create_test_deploy_template, + uuid=self.template.uuid, name='CUSTOM_DT2') + + def test_create_duplicate_name(self): + uuid = uuidutils.generate_uuid() + self.assertRaises(exception.DeployTemplateDuplicateName, + db_utils.create_test_deploy_template, + uuid=uuid, name=self.template.name) + + def test_create_invalid_step_no_interface(self): + uuid = uuidutils.generate_uuid() + template = db_utils.get_test_deploy_template(uuid=uuid, + name='CUSTOM_DT2') + del template['steps'][0]['interface'] + self.assertRaises(db_exc.DBError, + self.dbapi.create_deploy_template, + template, None) + + def test_update_name(self): + values = {'name': 'CUSTOM_DT2'} + template = self.dbapi.update_deploy_template(self.template.id, values) + self.assertEqual('CUSTOM_DT2', template.name) + + def test_update_steps_replace(self): + step = {'interface': 'bios', 'step': 'apply_configuration', + 'args': {}, 'priority': 50} + values = {'steps': [step]} + template = self.dbapi.update_deploy_template(self.template.id, values) + self.assertEqual(1, len(template.steps)) + step = template.steps[0] + self.assertEqual('bios', step.interface) + self.assertEqual('apply_configuration', step.step) + self.assertEqual({}, step.args) + self.assertEqual(50, step.priority) + + def test_update_steps_add(self): + step = {'interface': 'bios', 'step': 'apply_configuration', + 'args': {}, 'priority': 50} + values = {'steps': [self.template.steps[0], step]} + template = self.dbapi.update_deploy_template(self.template.id, values) + self.assertEqual(2, len(template.steps)) + step0 = template.steps[0] + self.assertEqual(self.template.steps[0].id, step0.id) + self.assertEqual('raid', step0.interface) + self.assertEqual('create_configuration', step0.step) + self.assertEqual({'logical_disks': []}, step0.args) + self.assertEqual(10, step0.priority) + step1 = template.steps[1] + self.assertNotEqual(self.template.steps[0].id, step1.id) + self.assertEqual('bios', step1.interface) + self.assertEqual('apply_configuration', step1.step) + self.assertEqual({}, step1.args) + self.assertEqual(50, step1.priority) + + def test_update_steps_remove_all(self): + values = {'steps': []} + template = self.dbapi.update_deploy_template(self.template.id, values) + self.assertEqual([], template.steps) + + def test_update_duplicate_name(self): + uuid = uuidutils.generate_uuid() + template2 = db_utils.create_test_deploy_template(uuid=uuid, + name='CUSTOM_DT2') + values = {'name': self.template.name} + self.assertRaises(exception.DeployTemplateDuplicateName, + self.dbapi.update_deploy_template, template2.id, + values) + + def test_update_not_found(self): + self.assertRaises(exception.DeployTemplateNotFound, + self.dbapi.update_deploy_template, 123, {}) + + def test_update_uuid_not_allowed(self): + uuid = uuidutils.generate_uuid() + self.assertRaises(exception.InvalidParameterValue, + self.dbapi.update_deploy_template, + self.template.id, {'uuid': uuid}) + + def test_destroy(self): + self.dbapi.destroy_deploy_template(self.template.id) + # Attempt to retrieve the template to verify it is gone. + self.assertRaises(exception.DeployTemplateNotFound, + self.dbapi.get_deploy_template_by_id, + self.template.id) + # Ensure that the destroy_deploy_template returns the + # expected exception. + self.assertRaises(exception.DeployTemplateNotFound, + self.dbapi.destroy_deploy_template, + self.template.id) + + def test_get_deploy_template_by_id(self): + res = self.dbapi.get_deploy_template_by_id(self.template.id) + self.assertEqual(self.template.id, res.id) + self.assertEqual(self.template.name, res.name) + self.assertEqual(1, len(res.steps)) + self.assertEqual(self.template.id, res.steps[0].deploy_template_id) + self.assertRaises(exception.DeployTemplateNotFound, + self.dbapi.get_deploy_template_by_id, -1) + + def test_get_deploy_template_by_uuid(self): + res = self.dbapi.get_deploy_template_by_uuid(self.template.uuid) + self.assertEqual(self.template.id, res.id) + self.assertRaises(exception.DeployTemplateNotFound, + self.dbapi.get_deploy_template_by_uuid, -1) + + def test_get_deploy_template_by_name(self): + res = self.dbapi.get_deploy_template_by_name(self.template.name) + self.assertEqual(self.template.id, res.id) + self.assertRaises(exception.DeployTemplateNotFound, + self.dbapi.get_deploy_template_by_name, 'bogus') + + def _template_list_preparation(self): + uuids = [six.text_type(self.template.uuid)] + for i in range(1, 3): + template = db_utils.create_test_deploy_template( + uuid=uuidutils.generate_uuid(), + name='CUSTOM_DT%d' % (i + 1)) + uuids.append(six.text_type(template.uuid)) + return uuids + + def test_get_deploy_template_list(self): + uuids = self._template_list_preparation() + res = self.dbapi.get_deploy_template_list() + res_uuids = [r.uuid for r in res] + six.assertCountEqual(self, uuids, res_uuids) + + def test_get_deploy_template_list_sorted(self): + uuids = self._template_list_preparation() + res = self.dbapi.get_deploy_template_list(sort_key='uuid') + res_uuids = [r.uuid for r in res] + self.assertEqual(sorted(uuids), res_uuids) + + self.assertRaises(exception.InvalidParameterValue, + self.dbapi.get_deploy_template_list, sort_key='foo') + + def test_get_deploy_template_list_by_names(self): + self._template_list_preparation() + names = ['CUSTOM_DT2', 'CUSTOM_DT3'] + res = self.dbapi.get_deploy_template_list_by_names(names=names) + res_names = [r.name for r in res] + six.assertCountEqual(self, names, res_names) + + def test_get_deploy_template_list_by_names_no_match(self): + self._template_list_preparation() + names = ['CUSTOM_FOO'] + res = self.dbapi.get_deploy_template_list_by_names(names=names) + self.assertEqual([], res) diff --git a/ironic/tests/unit/db/utils.py b/ironic/tests/unit/db/utils.py index 40fddf7c60..7b6be120a5 100644 --- a/ironic/tests/unit/db/utils.py +++ b/ironic/tests/unit/db/utils.py @@ -25,6 +25,7 @@ from ironic.objects import allocation from ironic.objects import bios from ironic.objects import chassis from ironic.objects import conductor +from ironic.objects import deploy_template from ironic.objects import node from ironic.objects import port from ironic.objects import portgroup @@ -620,3 +621,51 @@ def create_test_allocation(**kw): del allocation['id'] dbapi = db_api.get_instance() return dbapi.create_allocation(allocation) + + +def get_test_deploy_template(**kw): + return { + 'version': kw.get('version', deploy_template.DeployTemplate.VERSION), + 'created_at': kw.get('created_at'), + 'updated_at': kw.get('updated_at'), + 'id': kw.get('id', 234), + 'name': kw.get('name', u'CUSTOM_DT1'), + 'uuid': kw.get('uuid', 'aa75a317-2929-47d4-b676-fd9bff578bf1'), + 'steps': kw.get('steps', [get_test_deploy_template_step( + deploy_template_id=kw.get('id', 234))]), + } + + +def get_test_deploy_template_step(**kw): + return { + 'created_at': kw.get('created_at'), + 'updated_at': kw.get('updated_at'), + 'id': kw.get('id', 345), + 'deploy_template_id': kw.get('deploy_template_id', 234), + 'interface': kw.get('interface', 'raid'), + 'step': kw.get('step', 'create_configuration'), + 'args': kw.get('args', {'logical_disks': []}), + 'priority': kw.get('priority', 10), + } + + +def create_test_deploy_template(**kw): + """Create a deployment template in the DB and return DeployTemplate model. + + :param kw: kwargs with overriding values for the deploy template. + :returns: Test DeployTemplate DB object. + """ + template = get_test_deploy_template(**kw) + dbapi = db_api.get_instance() + # Let DB generate an ID if one isn't specified explicitly. + if 'id' not in kw: + del template['id'] + if 'steps' not in kw: + for step in template['steps']: + del step['id'] + del step['deploy_template_id'] + else: + for kw_step, template_step in zip(kw['steps'], template['steps']): + if 'id' not in kw_step: + del template_step['id'] + return dbapi.create_deploy_template(template, template['version']) diff --git a/ironic/tests/unit/objects/test_deploy_template.py b/ironic/tests/unit/objects/test_deploy_template.py new file mode 100644 index 0000000000..e32c30f208 --- /dev/null +++ b/ironic/tests/unit/objects/test_deploy_template.py @@ -0,0 +1,154 @@ +# 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 mock + +from ironic.common import context +from ironic.db import api as dbapi +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 TestDeployTemplateObject(db_base.DbTestCase, obj_utils.SchemasTestMixIn): + + def setUp(self): + super(TestDeployTemplateObject, self).setUp() + self.ctxt = context.get_admin_context() + self.fake_template = db_utils.get_test_deploy_template() + + @mock.patch.object(dbapi.IMPL, 'create_deploy_template', autospec=True) + def test_create(self, mock_create): + template = objects.DeployTemplate(context=self.context, + **self.fake_template) + + mock_create.return_value = db_utils.get_test_deploy_template() + + template.create() + + args, _kwargs = mock_create.call_args + self.assertEqual(objects.DeployTemplate.VERSION, args[0]['version']) + self.assertEqual(1, mock_create.call_count) + + self.assertEqual(self.fake_template['name'], template.name) + self.assertEqual(self.fake_template['steps'], template.steps) + + @mock.patch.object(dbapi.IMPL, 'update_deploy_template', autospec=True) + def test_save(self, mock_update): + template = objects.DeployTemplate(context=self.context, + **self.fake_template) + template.obj_reset_changes() + + mock_update.return_value = db_utils.get_test_deploy_template( + name='CUSTOM_DT2') + + template.name = 'CUSTOM_DT2' + template.save() + + mock_update.assert_called_once_with( + self.fake_template['uuid'], + {'name': 'CUSTOM_DT2', 'version': objects.DeployTemplate.VERSION}) + + self.assertEqual('CUSTOM_DT2', template.name) + + @mock.patch.object(dbapi.IMPL, 'destroy_deploy_template', autospec=True) + def test_destroy(self, mock_destroy): + template = objects.DeployTemplate(context=self.context, + id=self.fake_template['id']) + + template.destroy() + + mock_destroy.assert_called_once_with(self.fake_template['id']) + + @mock.patch.object(dbapi.IMPL, 'get_deploy_template_by_id', autospec=True) + def test_get_by_id(self, mock_get): + mock_get.return_value = self.fake_template + + template = objects.DeployTemplate.get_by_id( + self.context, self.fake_template['id']) + + mock_get.assert_called_once_with(self.fake_template['id']) + self.assertEqual(self.fake_template['name'], template.name) + self.assertEqual(self.fake_template['uuid'], template.uuid) + self.assertEqual(self.fake_template['steps'], template.steps) + + @mock.patch.object(dbapi.IMPL, 'get_deploy_template_by_uuid', + autospec=True) + def test_get_by_uuid(self, mock_get): + mock_get.return_value = self.fake_template + + template = objects.DeployTemplate.get_by_uuid( + self.context, self.fake_template['uuid']) + + mock_get.assert_called_once_with(self.fake_template['uuid']) + self.assertEqual(self.fake_template['name'], template.name) + self.assertEqual(self.fake_template['uuid'], template.uuid) + self.assertEqual(self.fake_template['steps'], template.steps) + + @mock.patch.object(dbapi.IMPL, 'get_deploy_template_by_name', + autospec=True) + def test_get_by_name(self, mock_get): + mock_get.return_value = self.fake_template + + template = objects.DeployTemplate.get_by_name( + self.context, self.fake_template['name']) + + mock_get.assert_called_once_with(self.fake_template['name']) + self.assertEqual(self.fake_template['name'], template.name) + self.assertEqual(self.fake_template['uuid'], template.uuid) + self.assertEqual(self.fake_template['steps'], template.steps) + + @mock.patch.object(dbapi.IMPL, 'get_deploy_template_list', autospec=True) + def test_list(self, mock_list): + mock_list.return_value = [self.fake_template] + + templates = objects.DeployTemplate.list(self.context) + + mock_list.assert_called_once_with(limit=None, marker=None, + sort_dir=None, sort_key=None) + self.assertEqual(1, len(templates)) + self.assertEqual(self.fake_template['name'], templates[0].name) + self.assertEqual(self.fake_template['uuid'], templates[0].uuid) + self.assertEqual(self.fake_template['steps'], templates[0].steps) + + @mock.patch.object(dbapi.IMPL, 'get_deploy_template_list_by_names', + autospec=True) + def test_list_by_names(self, mock_list): + mock_list.return_value = [self.fake_template] + + names = [self.fake_template['name']] + templates = objects.DeployTemplate.list_by_names(self.context, names) + + mock_list.assert_called_once_with(names) + self.assertEqual(1, len(templates)) + self.assertEqual(self.fake_template['name'], templates[0].name) + self.assertEqual(self.fake_template['uuid'], templates[0].uuid) + self.assertEqual(self.fake_template['steps'], templates[0].steps) + + @mock.patch.object(dbapi.IMPL, 'get_deploy_template_by_uuid', + autospec=True) + def test_refresh(self, mock_get): + uuid = self.fake_template['uuid'] + mock_get.side_effect = [dict(self.fake_template), + dict(self.fake_template, name='CUSTOM_DT2')] + + template = objects.DeployTemplate.get_by_uuid(self.context, uuid) + + self.assertEqual(self.fake_template['name'], template.name) + + template.refresh() + + self.assertEqual('CUSTOM_DT2', template.name) + expected = [mock.call(uuid), mock.call(uuid)] + self.assertEqual(expected, mock_get.call_args_list) + self.assertEqual(self.context, template._context) diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index 020a659d61..e1877832ab 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -717,6 +717,7 @@ expected_object_fingerprints = { 'Allocation': '1.0-25ebf609743cd3f332a4f80fcb818102', 'AllocationCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'AllocationCRUDPayload': '1.0-a82389d019f37cfe54b50049f73911b3', + 'DeployTemplate': '1.0-c20a91a34a5518e13b2a1bf5072eb119', } diff --git a/ironic/tests/unit/objects/utils.py b/ironic/tests/unit/objects/utils.py index 51185d97a1..ccc8006073 100644 --- a/ironic/tests/unit/objects/utils.py +++ b/ironic/tests/unit/objects/utils.py @@ -296,6 +296,41 @@ def create_test_allocation(ctxt, **kw): return allocation +def get_test_deploy_template(ctxt, **kw): + """Return a DeployTemplate object with appropriate attributes. + + NOTE: The object leaves the attributes marked as changed, such + that a create() could be used to commit it to the DB. + """ + db_template = db_utils.get_test_deploy_template(**kw) + # Let DB generate ID if it isn't specified explicitly + if 'id' not in kw: + del db_template['id'] + if 'steps' not in kw: + for step in db_template['steps']: + del step['id'] + del step['deploy_template_id'] + else: + for kw_step, template_step in zip(kw['steps'], db_template['steps']): + if 'id' not in kw_step and 'id' in template_step: + del template_step['id'] + template = objects.DeployTemplate(ctxt) + for key in db_template: + setattr(template, key, db_template[key]) + return template + + +def create_test_deploy_template(ctxt, **kw): + """Create and return a test deploy template object. + + NOTE: The object leaves the attributes marked as changed, such + that a create() could be used to commit it to the DB. + """ + template = get_test_deploy_template(ctxt, **kw) + template.create() + return template + + def get_payloads_with_schemas(from_module): """Get the Payload classes with SCHEMAs defined.