Deploy templates: data model, DB API & objects

Adds deploy_templates and deploy_template_steps tables to the database,
provides a DB API for these tables, and a DeployTemplate versioned
object.

Change-Id: I5b8b59bbea1594b1220438050b80f1c603dbc346
Story: 1722275
Task: 28674
This commit is contained in:
Mark Goddard 2018-12-27 12:48:11 +00:00
parent 0d19732089
commit b137af30b9
17 changed files with 1189 additions and 1 deletions

View File

@ -83,6 +83,9 @@ ONLINE_MIGRATIONS = (
NEW_MODELS = [
# TODO(dtantsur): remove in Train
'Allocation',
# TODO(mgoddard): remove in Train
'DeployTemplate',
'DeployTemplateStep',
]

View File

@ -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.")

View File

@ -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'],

View File

@ -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.
"""

View File

@ -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'
)

View File

@ -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()

View File

@ -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.

View File

@ -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')

View File

@ -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()

View File

@ -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

View File

@ -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.

View File

@ -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')

View File

@ -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)

View File

@ -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'])

View File

@ -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)

View File

@ -717,6 +717,7 @@ expected_object_fingerprints = {
'Allocation': '1.0-25ebf609743cd3f332a4f80fcb818102',
'AllocationCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
'AllocationCRUDPayload': '1.0-a82389d019f37cfe54b50049f73911b3',
'DeployTemplate': '1.0-c20a91a34a5518e13b2a1bf5072eb119',
}

View File

@ -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.