Deploy templates: API & notifications

Adds deploy_templates REST API endpoints for retrieving, creating,
updating and deleting deployment templates. Also adds notification
objects for deploy templates.

Bumps the minimum WSME requirement to 0.9.3, since the lower constraints
job was failing with a 500 error when sending data in an unexpected
format to the POST /deploy_templates API.

Change-Id: I0e8c97e600f9b1080c8bdec790e5710e7a92d016
Story: 1722275
Task: 28677
This commit is contained in:
Mark Goddard 2019-01-04 10:12:18 +00:00
parent 17a944fe9d
commit ec2f7f992e
18 changed files with 1654 additions and 17 deletions

View File

@ -2,6 +2,20 @@
REST API Version History
========================
1.55 (Stein, master)
--------------------
Added the following new endpoints for deploy templates:
* ``GET /v1/deploy_templates`` to list all deploy templates.
* ``GET /v1/deploy_templates/<deploy template identifier>`` to retrieve details
of a deploy template.
* ``POST /v1/deploy_templates`` to create a deploy template.
* ``PATCH /v1/deploy_templates/<deploy template identifier>`` to update a
deploy template.
* ``DELETE /v1/deploy_templates/<deploy template identifier>`` to delete a
deploy template.
1.54 (Stein, master)
--------------------

View File

@ -22,7 +22,28 @@ from wsme import types as wtypes
from ironic.common.i18n import _
class APIBase(wtypes.Base):
class AsDictMixin(object):
"""Mixin class adding an as_dict() method."""
def as_dict(self):
"""Render this object as a dict of its fields."""
def _attr_as_pod(attr):
"""Return an attribute as a Plain Old Data (POD) type."""
if isinstance(attr, list):
return [_attr_as_pod(item) for item in attr]
# Recursively evaluate objects that support as_dict().
try:
return attr.as_dict()
except AttributeError:
return attr
return dict((k, _attr_as_pod(getattr(self, k)))
for k in self.fields
if hasattr(self, k)
and getattr(self, k) != wsme.Unset)
class APIBase(wtypes.Base, AsDictMixin):
created_at = wsme.wsattr(datetime.datetime, readonly=True)
"""The time in UTC at which the object is created"""
@ -30,13 +51,6 @@ class APIBase(wtypes.Base):
updated_at = wsme.wsattr(datetime.datetime, readonly=True)
"""The time in UTC at which the object is updated"""
def as_dict(self):
"""Render this object as a dict of its fields."""
return dict((k, getattr(self, k))
for k in self.fields
if hasattr(self, k)
and getattr(self, k) != wsme.Unset)
def unset_fields_except(self, except_list=None):
"""Unset fields so they don't appear in the message body.

View File

@ -28,6 +28,7 @@ from ironic.api.controllers import link
from ironic.api.controllers.v1 import allocation
from ironic.api.controllers.v1 import chassis
from ironic.api.controllers.v1 import conductor
from ironic.api.controllers.v1 import deploy_template
from ironic.api.controllers.v1 import driver
from ironic.api.controllers.v1 import event
from ironic.api.controllers.v1 import node
@ -109,6 +110,9 @@ class V1(base.APIBase):
allocations = [link.Link]
"""Links to the allocations resource"""
deploy_templates = [link.Link]
"""Links to the deploy_templates resource"""
version = version.Version
"""Version discovery information."""
@ -216,6 +220,16 @@ class V1(base.APIBase):
'events', '',
bookmark=True)
]
if utils.allow_deploy_templates():
v1.deploy_templates = [
link.Link.make_link('self',
pecan.request.public_url,
'deploy_templates', ''),
link.Link.make_link('bookmark',
pecan.request.public_url,
'deploy_templates', '',
bookmark=True)
]
v1.version = version.default_version()
return v1
@ -234,6 +248,7 @@ class Controller(rest.RestController):
conductors = conductor.ConductorsController()
allocations = allocation.AllocationsController()
events = event.EventsController()
deploy_templates = deploy_template.DeployTemplatesController()
@expose.expose(V1)
def get(self):

View File

@ -0,0 +1,446 @@
# 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 collections
import datetime
from ironic_lib import metrics_utils
from oslo_log import log
from oslo_utils import strutils
from oslo_utils import uuidutils
import pecan
from pecan import rest
from six.moves import http_client
import wsme
from wsme import types as wtypes
from ironic.api.controllers import base
from ironic.api.controllers import link
from ironic.api.controllers.v1 import collection
from ironic.api.controllers.v1 import notification_utils as notify
from ironic.api.controllers.v1 import types
from ironic.api.controllers.v1 import utils as api_utils
from ironic.api import expose
from ironic.common import exception
from ironic.common.i18n import _
from ironic.conductor import utils as conductor_utils
import ironic.conf
from ironic import objects
CONF = ironic.conf.CONF
LOG = log.getLogger(__name__)
METRICS = metrics_utils.get_metrics_logger(__name__)
_DEFAULT_RETURN_FIELDS = ('uuid', 'name')
_DEPLOY_INTERFACE_TYPE = wtypes.Enum(
wtypes.text, *conductor_utils.DEPLOYING_INTERFACE_PRIORITY)
def _check_api_version():
if not api_utils.allow_deploy_templates():
raise exception.NotFound()
class DeployStepType(wtypes.Base, base.AsDictMixin):
"""A type describing a deployment step."""
interface = wsme.wsattr(_DEPLOY_INTERFACE_TYPE, mandatory=True)
step = wsme.wsattr(wtypes.text, mandatory=True)
args = wsme.wsattr({wtypes.text: types.jsontype}, mandatory=True)
priority = wsme.wsattr(wtypes.IntegerType(0), mandatory=True)
def __init__(self, **kwargs):
self.fields = ['interface', 'step', 'args', 'priority']
for field in self.fields:
value = kwargs.get(field, wtypes.Unset)
setattr(self, field, value)
def sanitize(self):
"""Removes sensitive data."""
if self.args != wtypes.Unset:
self.args = strutils.mask_dict_password(self.args, "******")
class DeployTemplate(base.APIBase):
"""API representation of a deploy template."""
uuid = types.uuid
"""Unique UUID for this deploy template."""
name = wsme.wsattr(wtypes.text, mandatory=True)
"""The logical name for this deploy template."""
steps = wsme.wsattr([DeployStepType], mandatory=True)
"""The deploy steps of this deploy template."""
links = wsme.wsattr([link.Link])
"""A list containing a self link and associated deploy template links."""
extra = {wtypes.text: types.jsontype}
"""This deploy template's meta data"""
def __init__(self, **kwargs):
self.fields = []
fields = list(objects.DeployTemplate.fields)
for field in fields:
# Skip fields we do not expose.
if not hasattr(self, field):
continue
value = kwargs.get(field, wtypes.Unset)
if field == 'steps' and value != wtypes.Unset:
value = [DeployStepType(**step) for step in value]
self.fields.append(field)
setattr(self, field, value)
@staticmethod
def validate(value):
if value is None:
return
# The name is mandatory, but the 'mandatory' attribute support in
# wtypes.wsattr allows None.
if value.name is None:
err = _("Deploy template name cannot be None")
raise exception.InvalidDeployTemplate(err=err)
# The name must also be a valid trait.
api_utils.validate_trait(
value.name, _("Deploy template name must be a valid trait"))
# There must be at least one step.
if not value.steps:
err = _("No deploy steps specified. A deploy template must have "
"at least one deploy step.")
raise exception.InvalidDeployTemplate(err=err)
# TODO(mgoddard): Determine the consequences of allowing duplicate
# steps.
# * What if one step has zero priority and another non-zero?
# * What if a step that is enabled by default is included in a
# template? Do we override the default or add a second invocation?
# Check for duplicate steps. Each interface/step combination can be
# specified at most once.
counter = collections.Counter((step.interface, step.step)
for step in value.steps)
duplicates = {key for key, count in counter.items() if count > 1}
if duplicates:
duplicates = {"interface: %s, step: %s" % (interface, step)
for interface, step in duplicates}
err = _("Duplicate deploy steps. A deploy template cannot have "
"multiple deploy steps with the same interface and step. "
"Duplicates: %s") % "; ".join(duplicates)
raise exception.InvalidDeployTemplate(err=err)
return value
@staticmethod
def _convert_with_links(template, url, fields=None):
template.links = [
link.Link.make_link('self', url, 'deploy_templates',
template.uuid),
link.Link.make_link('bookmark', url, 'deploy_templates',
template.uuid,
bookmark=True)
]
return template
@classmethod
def convert_with_links(cls, rpc_template, fields=None, sanitize=True):
"""Add links to the deploy template."""
template = DeployTemplate(**rpc_template.as_dict())
if fields is not None:
api_utils.check_for_invalid_fields(fields, template.as_dict())
template = cls._convert_with_links(template,
pecan.request.public_url,
fields=fields)
if sanitize:
template.sanitize(fields)
return template
def sanitize(self, fields):
"""Removes sensitive and unrequested data.
Will only keep the fields specified in the ``fields`` parameter.
:param fields:
list of fields to preserve, or ``None`` to preserve them all
:type fields: list of str
"""
if self.steps != wtypes.Unset:
for step in self.steps:
step.sanitize()
if fields is not None:
self.unset_fields_except(fields)
@classmethod
def sample(cls, expand=True):
time = datetime.datetime(2000, 1, 1, 12, 0, 0)
template_uuid = '534e73fa-1014-4e58-969a-814cc0cb9d43'
template_name = 'CUSTOM_RAID1'
template_steps = [{
"interface": "raid",
"step": "create_configuration",
"args": {
"logical_disks": [{
"size_gb": "MAX",
"raid_level": "1",
"is_root_volume": True
}],
"delete_configuration": True
},
"priority": 10
}]
template_extra = {'foo': 'bar'}
sample = cls(uuid=template_uuid,
name=template_name,
steps=template_steps,
extra=template_extra,
created_at=time,
updated_at=time)
fields = None if expand else _DEFAULT_RETURN_FIELDS
return cls._convert_with_links(sample, 'http://localhost:6385',
fields=fields)
class DeployTemplatePatchType(types.JsonPatchType):
_api_base = DeployTemplate
class DeployTemplateCollection(collection.Collection):
"""API representation of a collection of deploy templates."""
_type = 'deploy_templates'
deploy_templates = [DeployTemplate]
"""A list containing deploy template objects"""
@staticmethod
def convert_with_links(templates, limit, fields=None, **kwargs):
collection = DeployTemplateCollection()
collection.deploy_templates = [
DeployTemplate.convert_with_links(t, fields=fields, sanitize=False)
for t in templates]
collection.next = collection.get_next(limit, **kwargs)
for template in collection.deploy_templates:
template.sanitize(fields)
return collection
@classmethod
def sample(cls):
sample = cls()
template = DeployTemplate.sample(expand=False)
sample.deploy_templates = [template]
return sample
class DeployTemplatesController(rest.RestController):
"""REST controller for deploy templates."""
invalid_sort_key_list = ['extra', 'steps']
def _update_changed_fields(self, template, rpc_template):
"""Update rpc_template based on changed fields in a template."""
for field in objects.DeployTemplate.fields:
try:
patch_val = getattr(template, field)
except AttributeError:
# Ignore fields that aren't exposed in the API.
continue
if patch_val == wtypes.Unset:
patch_val = None
if rpc_template[field] != patch_val:
if field == 'steps' and patch_val is not None:
# Convert from DeployStepType to dict.
patch_val = [s.as_dict() for s in patch_val]
rpc_template[field] = patch_val
@METRICS.timer('DeployTemplatesController.get_all')
@expose.expose(DeployTemplateCollection, types.name, int, wtypes.text,
wtypes.text, types.listtype, types.boolean)
def get_all(self, marker=None, limit=None, sort_key='id', sort_dir='asc',
fields=None, detail=None):
"""Retrieve a list of deploy templates.
:param marker: pagination marker for large data sets.
:param limit: maximum number of resources to return in a single result.
This value cannot be larger than the value of max_limit
in the [api] section of the ironic configuration, or only
max_limit resources will be returned.
:param sort_key: column to sort results by. Default: id.
:param sort_dir: direction to sort. "asc" or "desc". Default: asc.
:param fields: Optional, a list with a specified set of fields
of the resource to be returned.
:param detail: Optional, boolean to indicate whether retrieve a list
of deploy templates with detail.
"""
_check_api_version()
api_utils.check_policy('baremetal:deploy_template:get')
api_utils.check_allowed_fields(fields)
api_utils.check_allowed_fields([sort_key])
fields = api_utils.get_request_return_fields(fields, detail,
_DEFAULT_RETURN_FIELDS)
limit = api_utils.validate_limit(limit)
sort_dir = api_utils.validate_sort_dir(sort_dir)
if sort_key in self.invalid_sort_key_list:
raise exception.InvalidParameterValue(
_("The sort_key value %(key)s is an invalid field for "
"sorting") % {'key': sort_key})
marker_obj = None
if marker:
marker_obj = objects.DeployTemplate.get_by_uuid(
pecan.request.context, marker)
templates = objects.DeployTemplate.list(
pecan.request.context, limit=limit, marker=marker_obj,
sort_key=sort_key, sort_dir=sort_dir)
parameters = {'sort_key': sort_key, 'sort_dir': sort_dir}
if detail is not None:
parameters['detail'] = detail
return DeployTemplateCollection.convert_with_links(
templates, limit, fields=fields, **parameters)
@METRICS.timer('DeployTemplatesController.get_one')
@expose.expose(DeployTemplate, types.uuid_or_name, types.listtype)
def get_one(self, template_ident, fields=None):
"""Retrieve information about the given deploy template.
:param template_ident: UUID or logical name of a deploy template.
:param fields: Optional, a list with a specified set of fields
of the resource to be returned.
"""
_check_api_version()
api_utils.check_policy('baremetal:deploy_template:get')
api_utils.check_allowed_fields(fields)
rpc_template = api_utils.get_rpc_deploy_template_with_suffix(
template_ident)
return DeployTemplate.convert_with_links(rpc_template, fields=fields)
@METRICS.timer('DeployTemplatesController.post')
@expose.expose(DeployTemplate, body=DeployTemplate,
status_code=http_client.CREATED)
def post(self, template):
"""Create a new deploy template.
:param template: a deploy template within the request body.
"""
_check_api_version()
api_utils.check_policy('baremetal:deploy_template:create')
context = pecan.request.context
tdict = template.as_dict()
# NOTE(mgoddard): UUID is mandatory for notifications payload
if not tdict.get('uuid'):
tdict['uuid'] = uuidutils.generate_uuid()
new_template = objects.DeployTemplate(context, **tdict)
notify.emit_start_notification(context, new_template, 'create')
with notify.handle_error_notification(context, new_template, 'create'):
new_template.create()
# Set the HTTP Location Header
pecan.response.location = link.build_url('deploy_templates',
new_template.uuid)
api_template = DeployTemplate.convert_with_links(new_template)
notify.emit_end_notification(context, new_template, 'create')
return api_template
@METRICS.timer('DeployTemplatesController.patch')
@wsme.validate(types.uuid, types.boolean, [DeployTemplatePatchType])
@expose.expose(DeployTemplate, types.uuid_or_name, types.boolean,
body=[DeployTemplatePatchType])
def patch(self, template_ident, patch=None):
"""Update an existing deploy template.
:param template_ident: UUID or logical name of a deploy template.
:param patch: a json PATCH document to apply to this deploy template.
"""
_check_api_version()
api_utils.check_policy('baremetal:deploy_template:update')
context = pecan.request.context
rpc_template = api_utils.get_rpc_deploy_template_with_suffix(
template_ident)
try:
template_dict = rpc_template.as_dict()
template = DeployTemplate(
**api_utils.apply_jsonpatch(template_dict, patch))
except api_utils.JSONPATCH_EXCEPTIONS as e:
raise exception.PatchError(patch=patch, reason=e)
template.validate(template)
self._update_changed_fields(template, rpc_template)
# NOTE(mgoddard): There could be issues with concurrent updates of a
# template. This is particularly true for the complex 'steps' field,
# where operations such as modifying a single step could result in
# changes being lost, e.g. two requests concurrently appending a step
# to the same template could result in only one of the steps being
# added, due to the read/modify/write nature of this patch operation.
# This issue should not be present for 'simple' string fields, or
# complete replacement of the steps (the only operation supported by
# the openstack baremetal CLI). It's likely that this is an issue for
# other resources, even those modified in the conductor under a lock.
# This is due to the fact that the patch operation is always applied in
# the API. Ways to avoid this include passing the patch to the
# conductor to apply while holding a lock, or a collision detection
# & retry mechansim using e.g. the updated_at field.
notify.emit_start_notification(context, rpc_template, 'update')
with notify.handle_error_notification(context, rpc_template, 'update'):
rpc_template.save()
api_template = DeployTemplate.convert_with_links(rpc_template)
notify.emit_end_notification(context, rpc_template, 'update')
return api_template
@METRICS.timer('DeployTemplatesController.delete')
@expose.expose(None, types.uuid_or_name,
status_code=http_client.NO_CONTENT)
def delete(self, template_ident):
"""Delete a deploy template.
:param template_ident: UUID or logical name of a deploy template.
"""
_check_api_version()
api_utils.check_policy('baremetal:deploy_template:delete')
context = pecan.request.context
rpc_template = api_utils.get_rpc_deploy_template_with_suffix(
template_ident)
notify.emit_start_notification(context, rpc_template, 'delete')
with notify.handle_error_notification(context, rpc_template, 'delete'):
rpc_template.destroy()
notify.emit_end_notification(context, rpc_template, 'delete')

View File

@ -23,6 +23,7 @@ from ironic.common import exception
from ironic.common.i18n import _
from ironic.objects import allocation as allocation_objects
from ironic.objects import chassis as chassis_objects
from ironic.objects import deploy_template as deploy_template_objects
from ironic.objects import fields
from ironic.objects import node as node_objects
from ironic.objects import notification
@ -40,6 +41,8 @@ CRUD_NOTIFY_OBJ = {
allocation_objects.AllocationCRUDPayload),
'chassis': (chassis_objects.ChassisCRUDNotification,
chassis_objects.ChassisCRUDPayload),
'deploytemplate': (deploy_template_objects.DeployTemplateCRUDNotification,
deploy_template_objects.DeployTemplateCRUDPayload),
'node': (node_objects.NodeCRUDNotification,
node_objects.NodeCRUDPayload),
'port': (port_objects.PortCRUDNotification,

View File

@ -31,6 +31,7 @@ from ironic.api.controllers.v1 import versions
from ironic.common import exception
from ironic.common import faults
from ironic.common.i18n import _
from ironic.common import policy
from ironic.common import states
from ironic.common import utils
from ironic import objects
@ -41,7 +42,8 @@ CONF = cfg.CONF
JSONPATCH_EXCEPTIONS = (jsonpatch.JsonPatchException,
jsonpatch.JsonPointerException,
KeyError)
KeyError,
IndexError)
# Minimum API version to use for certain verbs
@ -92,12 +94,16 @@ def validate_sort_dir(sort_dir):
return sort_dir
def validate_trait(trait):
def validate_trait(trait, error_prefix='Invalid trait'):
error = wsme.exc.ClientSideError(
_('Invalid trait. A valid trait must be no longer than 255 '
_('%(error_prefix)s. A valid trait must be no longer than 255 '
'characters. Standard traits are defined in the os_traits library. '
'A custom trait must start with the prefix CUSTOM_ and use '
'the following characters: A-Z, 0-9 and _'))
'the following characters: A-Z, 0-9 and _') %
{'error_prefix': error_prefix})
if not isinstance(trait, six.string_types):
raise error
if len(trait) > 255 or len(trait) < 1:
raise error
@ -299,6 +305,45 @@ def get_rpc_allocation_with_suffix(allocation_ident):
exception.AllocationNotFound)
def get_rpc_deploy_template(template_ident):
"""Get the RPC deploy template from the UUID or logical name.
:param template_ident: the UUID or logical name of a deploy template.
:returns: The RPC deploy template.
:raises: InvalidUuidOrName if the name or uuid provided is not valid.
:raises: DeployTemplateNotFound if the deploy template is not found.
"""
# Check to see if the template_ident is a valid UUID. If it is, treat it
# as a UUID.
if uuidutils.is_uuid_like(template_ident):
return objects.DeployTemplate.get_by_uuid(pecan.request.context,
template_ident)
# We can refer to templates by their name
if utils.is_valid_logical_name(template_ident):
return objects.DeployTemplate.get_by_name(pecan.request.context,
template_ident)
raise exception.InvalidUuidOrName(name=template_ident)
def get_rpc_deploy_template_with_suffix(template_ident):
"""Get the RPC deploy template from the UUID or logical name.
If HAS_JSON_SUFFIX flag is set in the pecan environment, try also looking
for template_ident with '.json' suffix. Otherwise identical
to get_rpc_deploy_template.
:param template_ident: the UUID or logical name of a deploy template.
:returns: The RPC deploy template.
:raises: InvalidUuidOrName if the name or uuid provided is not valid.
:raises: DeployTemplateNotFound if the deploy template is not found.
"""
return _get_with_suffix(get_rpc_deploy_template, template_ident,
exception.DeployTemplateNotFound)
def is_valid_node_name(name):
"""Determine if the provided name is a valid node name.
@ -1031,3 +1076,21 @@ def allow_expose_events():
Version 1.54 of the API added the events endpoint.
"""
return pecan.request.version.minor >= versions.MINOR_54_EVENTS
def allow_deploy_templates():
"""Check if accessing deploy template endpoints is allowed.
Version 1.55 of the API exposed deploy template endpoints.
"""
return pecan.request.version.minor >= versions.MINOR_55_DEPLOY_TEMPLATES
def check_policy(policy_name):
"""Check if the specified policy is authorised for this request.
:policy_name: Name of the policy to check.
:raises: HTTPForbidden if the policy forbids access.
"""
cdict = pecan.request.context.to_policy_values()
policy.authorize(policy_name, cdict, cdict)

View File

@ -148,6 +148,7 @@ MINOR_51_NODE_DESCRIPTION = 51
MINOR_52_ALLOCATION = 52
MINOR_53_PORT_SMARTNIC = 53
MINOR_54_EVENTS = 54
MINOR_55_DEPLOY_TEMPLATES = 55
# When adding another version, update:
# - MINOR_MAX_VERSION
@ -155,7 +156,7 @@ MINOR_54_EVENTS = 54
# explanation of what changed in the new version
# - common/release_mappings.py, RELEASE_MAPPING['master']['api']
MINOR_MAX_VERSION = MINOR_54_EVENTS
MINOR_MAX_VERSION = MINOR_55_DEPLOY_TEMPLATES
# String representations of the minor and maximum versions
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)

View File

@ -819,3 +819,7 @@ class DeployTemplateAlreadyExists(Conflict):
class DeployTemplateNotFound(NotFound):
_msg_fmt = _("Deploy template %(template)s could not be found.")
class InvalidDeployTemplate(Invalid):
_msg_fmt = _("Deploy template invalid: %(err)s.")

View File

@ -434,6 +434,34 @@ event_policies = [
]
deploy_template_policies = [
policy.DocumentedRuleDefault(
'baremetal:deploy_template:get',
'rule:is_admin or rule:is_observer',
'Retrieve Deploy Template records',
[{'path': '/deploy_templates', 'method': 'GET'},
{'path': '/deploy_templates/{deploy_template_ident}',
'method': 'GET'}]),
policy.DocumentedRuleDefault(
'baremetal:deploy_template:create',
'rule:is_admin',
'Create Deploy Template records',
[{'path': '/deploy_templates', 'method': 'POST'}]),
policy.DocumentedRuleDefault(
'baremetal:deploy_template:delete',
'rule:is_admin',
'Delete Deploy Template records',
[{'path': '/deploy_templates/{deploy_template_ident}',
'method': 'DELETE'}]),
policy.DocumentedRuleDefault(
'baremetal:deploy_template:update',
'rule:is_admin',
'Update Deploy Template records',
[{'path': '/deploy_templates/{deploy_template_ident}',
'method': 'PATCH'}]),
]
def list_policies():
policies = itertools.chain(
default_policies,
@ -447,7 +475,8 @@ def list_policies():
volume_policies,
conductor_policies,
allocation_policies,
event_policies
event_policies,
deploy_template_policies,
)
return policies

View File

@ -131,7 +131,7 @@ RELEASE_MAPPING = {
}
},
'master': {
'api': '1.54',
'api': '1.55',
'rpc': '1.48',
'objects': {
'Allocation': ['1.0'],

View File

@ -15,6 +15,7 @@ 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
from ironic.objects import notification
@base.IronicObjectRegistry.register
@ -239,3 +240,42 @@ class DeployTemplate(base.IronicObject, object_base.VersionedObjectDictCompat):
current = self.get_by_uuid(self._context, uuid=self.uuid)
self.obj_refresh(current)
self.obj_reset_changes()
@base.IronicObjectRegistry.register
class DeployTemplateCRUDNotification(notification.NotificationBase):
"""Notification emitted on deploy template API operations."""
# Version 1.0: Initial version
VERSION = '1.0'
fields = {
'payload': object_fields.ObjectField('DeployTemplateCRUDPayload')
}
@base.IronicObjectRegistry.register
class DeployTemplateCRUDPayload(notification.NotificationPayloadBase):
# Version 1.0: Initial version
VERSION = '1.0'
SCHEMA = {
'created_at': ('deploy_template', 'created_at'),
'extra': ('deploy_template', 'extra'),
'name': ('deploy_template', 'name'),
'steps': ('deploy_template', 'steps'),
'updated_at': ('deploy_template', 'updated_at'),
'uuid': ('deploy_template', 'uuid')
}
fields = {
'created_at': object_fields.DateTimeField(nullable=True),
'extra': object_fields.FlexibleDictField(nullable=True),
'name': object_fields.StringField(nullable=False),
'steps': object_fields.ListOfFlexibleDictsField(nullable=False),
'updated_at': object_fields.DateTimeField(nullable=True),
'uuid': object_fields.UUIDField()
}
def __init__(self, deploy_template, **kwargs):
super(DeployTemplateCRUDPayload, self).__init__(**kwargs)
self.populate_schema(deploy_template=deploy_template)

View File

@ -0,0 +1,942 @@
# 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 the API /deploy_templates/ methods.
"""
import datetime
import mock
from oslo_config import cfg
from oslo_utils import timeutils
from oslo_utils import uuidutils
import six
from six.moves import http_client
from six.moves.urllib import parse as urlparse
from ironic.api.controllers import base as api_base
from ironic.api.controllers import v1 as api_v1
from ironic.api.controllers.v1 import deploy_template as api_deploy_template
from ironic.api.controllers.v1 import notification_utils
from ironic.common import exception
from ironic import objects
from ironic.objects import fields as obj_fields
from ironic.tests import base
from ironic.tests.unit.api import base as test_api_base
from ironic.tests.unit.api import utils as test_api_utils
from ironic.tests.unit.objects import utils as obj_utils
def _obj_to_api_step(obj_step):
"""Convert a deploy step in 'object' form to one in 'API' form."""
return {
'interface': obj_step['interface'],
'step': obj_step['step'],
'args': obj_step['args'],
'priority': obj_step['priority'],
}
class TestDeployTemplateObject(base.TestCase):
def test_deploy_template_init(self):
template_dict = test_api_utils.deploy_template_post_data()
template = api_deploy_template.DeployTemplate(**template_dict)
self.assertEqual(template_dict['uuid'], template.uuid)
self.assertEqual(template_dict['name'], template.name)
self.assertEqual(template_dict['extra'], template.extra)
for t_dict_step, t_step in zip(template_dict['steps'], template.steps):
self.assertEqual(t_dict_step['interface'], t_step.interface)
self.assertEqual(t_dict_step['step'], t_step.step)
self.assertEqual(t_dict_step['args'], t_step.args)
self.assertEqual(t_dict_step['priority'], t_step.priority)
def test_deploy_template_sample(self):
sample = api_deploy_template.DeployTemplate.sample(expand=False)
self.assertEqual('534e73fa-1014-4e58-969a-814cc0cb9d43', sample.uuid)
self.assertEqual('CUSTOM_RAID1', sample.name)
self.assertEqual({'foo': 'bar'}, sample.extra)
class BaseDeployTemplatesAPITest(test_api_base.BaseApiTest):
headers = {api_base.Version.string: str(api_v1.max_version())}
invalid_version_headers = {api_base.Version.string: '1.54'}
class TestListDeployTemplates(BaseDeployTemplatesAPITest):
def test_empty(self):
data = self.get_json('/deploy_templates', headers=self.headers)
self.assertEqual([], data['deploy_templates'])
def test_one(self):
template = obj_utils.create_test_deploy_template(self.context)
data = self.get_json('/deploy_templates', headers=self.headers)
self.assertEqual(1, len(data['deploy_templates']))
self.assertEqual(template.uuid, data['deploy_templates'][0]['uuid'])
self.assertEqual(template.name, data['deploy_templates'][0]['name'])
self.assertNotIn('steps', data['deploy_templates'][0])
self.assertNotIn('extra', data['deploy_templates'][0])
def test_get_one(self):
template = obj_utils.create_test_deploy_template(self.context)
data = self.get_json('/deploy_templates/%s' % template.uuid,
headers=self.headers)
self.assertEqual(template.uuid, data['uuid'])
self.assertEqual(template.name, data['name'])
self.assertEqual(template.extra, data['extra'])
for t_dict_step, t_step in zip(data['steps'], template.steps):
self.assertEqual(t_dict_step['interface'], t_step['interface'])
self.assertEqual(t_dict_step['step'], t_step['step'])
self.assertEqual(t_dict_step['args'], t_step['args'])
self.assertEqual(t_dict_step['priority'], t_step['priority'])
def test_get_one_with_json(self):
template = obj_utils.create_test_deploy_template(self.context)
data = self.get_json('/deploy_templates/%s.json' % template.uuid,
headers=self.headers)
self.assertEqual(template.uuid, data['uuid'])
def test_get_one_with_suffix(self):
template = obj_utils.create_test_deploy_template(self.context,
name='CUSTOM_DT1')
data = self.get_json('/deploy_templates/%s' % template.uuid,
headers=self.headers)
self.assertEqual(template.uuid, data['uuid'])
def test_get_one_custom_fields(self):
template = obj_utils.create_test_deploy_template(self.context)
fields = 'name,steps'
data = self.get_json(
'/deploy_templates/%s?fields=%s' % (template.uuid, fields),
headers=self.headers)
# We always append "links"
self.assertItemsEqual(['name', 'steps', 'links'], data)
def test_get_collection_custom_fields(self):
fields = 'uuid,steps'
for i in range(3):
obj_utils.create_test_deploy_template(
self.context,
uuid=uuidutils.generate_uuid(),
name='CUSTOM_DT%s' % i)
data = self.get_json(
'/deploy_templates?fields=%s' % fields,
headers=self.headers)
self.assertEqual(3, len(data['deploy_templates']))
for template in data['deploy_templates']:
# We always append "links"
self.assertItemsEqual(['uuid', 'steps', 'links'], template)
def test_get_custom_fields_invalid_fields(self):
template = obj_utils.create_test_deploy_template(self.context)
fields = 'uuid,spongebob'
response = self.get_json(
'/deploy_templates/%s?fields=%s' % (template.uuid, fields),
headers=self.headers, expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertIn('spongebob', response.json['error_message'])
def test_get_all_invalid_api_version(self):
obj_utils.create_test_deploy_template(self.context)
response = self.get_json('/deploy_templates',
headers=self.invalid_version_headers,
expect_errors=True)
self.assertEqual(http_client.NOT_FOUND, response.status_int)
def test_get_one_invalid_api_version(self):
template = obj_utils.create_test_deploy_template(self.context)
response = self.get_json(
'/deploy_templates/%s' % (template.uuid),
headers=self.invalid_version_headers,
expect_errors=True)
self.assertEqual(http_client.NOT_FOUND, response.status_int)
def test_detail_query(self):
template = obj_utils.create_test_deploy_template(self.context)
data = self.get_json('/deploy_templates?detail=True',
headers=self.headers)
self.assertEqual(template.uuid, data['deploy_templates'][0]['uuid'])
self.assertIn('name', data['deploy_templates'][0])
self.assertIn('steps', data['deploy_templates'][0])
self.assertIn('extra', data['deploy_templates'][0])
def test_detail_query_false(self):
obj_utils.create_test_deploy_template(self.context)
data1 = self.get_json(
'/deploy_templates',
headers={api_base.Version.string: str(api_v1.max_version())})
data2 = self.get_json(
'/deploy_templates?detail=False',
headers={api_base.Version.string: str(api_v1.max_version())})
self.assertEqual(data1['deploy_templates'], data2['deploy_templates'])
def test_detail_using_query_false_and_fields(self):
obj_utils.create_test_deploy_template(self.context)
data = self.get_json(
'/deploy_templates?detail=False&fields=steps',
headers={api_base.Version.string: str(api_v1.max_version())})
self.assertIn('steps', data['deploy_templates'][0])
self.assertNotIn('uuid', data['deploy_templates'][0])
self.assertNotIn('extra', data['deploy_templates'][0])
def test_detail_using_query_and_fields(self):
obj_utils.create_test_deploy_template(self.context)
response = self.get_json(
'/deploy_templates?detail=True&fields=name',
headers={api_base.Version.string: str(api_v1.max_version())},
expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
def test_many(self):
templates = []
for id_ in range(5):
template = obj_utils.create_test_deploy_template(
self.context, uuid=uuidutils.generate_uuid(),
name='CUSTOM_DT%s' % id_)
templates.append(template.uuid)
data = self.get_json('/deploy_templates', headers=self.headers)
self.assertEqual(len(templates), len(data['deploy_templates']))
uuids = [n['uuid'] for n in data['deploy_templates']]
six.assertCountEqual(self, templates, uuids)
def test_links(self):
uuid = uuidutils.generate_uuid()
obj_utils.create_test_deploy_template(self.context, uuid=uuid)
data = self.get_json('/deploy_templates/%s' % uuid,
headers=self.headers)
self.assertIn('links', data)
self.assertEqual(2, len(data['links']))
self.assertIn(uuid, data['links'][0]['href'])
for l in data['links']:
bookmark = l['rel'] == 'bookmark'
self.assertTrue(self.validate_link(l['href'], bookmark=bookmark,
headers=self.headers))
def test_collection_links(self):
templates = []
for id_ in range(5):
template = obj_utils.create_test_deploy_template(
self.context, uuid=uuidutils.generate_uuid(),
name='CUSTOM_DT%s' % id_)
templates.append(template.uuid)
data = self.get_json('/deploy_templates/?limit=3',
headers=self.headers)
self.assertEqual(3, len(data['deploy_templates']))
next_marker = data['deploy_templates'][-1]['uuid']
self.assertIn(next_marker, data['next'])
def test_collection_links_default_limit(self):
cfg.CONF.set_override('max_limit', 3, 'api')
templates = []
for id_ in range(5):
template = obj_utils.create_test_deploy_template(
self.context, uuid=uuidutils.generate_uuid(),
name='CUSTOM_DT%s' % id_)
templates.append(template.uuid)
data = self.get_json('/deploy_templates', headers=self.headers)
self.assertEqual(3, len(data['deploy_templates']))
next_marker = data['deploy_templates'][-1]['uuid']
self.assertIn(next_marker, data['next'])
def test_get_collection_pagination_no_uuid(self):
fields = 'name'
limit = 2
templates = []
for id_ in range(3):
template = obj_utils.create_test_deploy_template(
self.context,
uuid=uuidutils.generate_uuid(),
name='CUSTOM_DT%s' % id_)
templates.append(template)
data = self.get_json(
'/deploy_templates?fields=%s&limit=%s' % (fields, limit),
headers=self.headers)
self.assertEqual(limit, len(data['deploy_templates']))
self.assertIn('marker=%s' % templates[limit - 1].uuid, data['next'])
def test_sort_key(self):
templates = []
for id_ in range(3):
template = obj_utils.create_test_deploy_template(
self.context,
uuid=uuidutils.generate_uuid(),
name='CUSTOM_DT%s' % id_)
templates.append(template.uuid)
data = self.get_json('/deploy_templates?sort_key=uuid',
headers=self.headers)
uuids = [n['uuid'] for n in data['deploy_templates']]
self.assertEqual(sorted(templates), uuids)
def test_sort_key_invalid(self):
invalid_keys_list = ['extra', 'foo', 'steps']
for invalid_key in invalid_keys_list:
path = '/deploy_templates?sort_key=%s' % invalid_key
response = self.get_json(path, expect_errors=True,
headers=self.headers)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertIn(invalid_key, response.json['error_message'])
def _test_sort_key_allowed(self, detail=False):
template_uuids = []
for id_ in range(3, 0, -1):
template = obj_utils.create_test_deploy_template(
self.context,
uuid=uuidutils.generate_uuid(),
name='CUSTOM_DT%s' % id_)
template_uuids.append(template.uuid)
template_uuids.reverse()
url = '/deploy_templates?sort_key=name&detail=%s' % str(detail)
data = self.get_json(url, headers=self.headers)
data_uuids = [p['uuid'] for p in data['deploy_templates']]
self.assertEqual(template_uuids, data_uuids)
def test_sort_key_allowed(self):
self._test_sort_key_allowed()
def test_detail_sort_key_allowed(self):
self._test_sort_key_allowed(detail=True)
def test_sensitive_data_masked(self):
template = obj_utils.get_test_deploy_template(self.context)
template.steps[0]['args']['password'] = 'correcthorsebatterystaple'
template.create()
data = self.get_json('/deploy_templates/%s' % template.uuid,
headers=self.headers)
self.assertEqual("******", data['steps'][0]['args']['password'])
@mock.patch.object(objects.DeployTemplate, 'save', autospec=True)
class TestPatch(BaseDeployTemplatesAPITest):
def setUp(self):
super(TestPatch, self).setUp()
self.template = obj_utils.create_test_deploy_template(
self.context, name='CUSTOM_DT1')
def _test_update_ok(self, mock_save, patch):
response = self.patch_json('/deploy_templates/%s' % self.template.uuid,
patch, headers=self.headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code)
mock_save.assert_called_once_with(mock.ANY)
return response
def _test_update_bad_request(self, mock_save, patch, error_msg):
response = self.patch_json('/deploy_templates/%s' % self.template.uuid,
patch, expect_errors=True,
headers=self.headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.BAD_REQUEST, response.status_code)
self.assertTrue(response.json['error_message'])
self.assertIn(error_msg, response.json['error_message'])
self.assertFalse(mock_save.called)
return response
@mock.patch.object(notification_utils, '_emit_api_notification',
autospec=True)
def test_update_by_id(self, mock_notify, mock_save):
name = 'CUSTOM_DT2'
patch = [{'path': '/name', 'value': name, 'op': 'add'}]
response = self._test_update_ok(mock_save, patch)
self.assertEqual(name, response.json['name'])
mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update',
obj_fields.NotificationLevel.INFO,
obj_fields.NotificationStatus.START),
mock.call(mock.ANY, mock.ANY, 'update',
obj_fields.NotificationLevel.INFO,
obj_fields.NotificationStatus.END)])
def test_update_by_name(self, mock_save):
steps = [{
'interface': 'bios',
'step': 'apply_configuration',
'args': {'foo': 'bar'},
'priority': 42
}]
patch = [{'path': '/steps', 'value': steps, 'op': 'replace'}]
response = self.patch_json('/deploy_templates/%s' % self.template.name,
patch, headers=self.headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code)
mock_save.assert_called_once_with(mock.ANY)
self.assertEqual(steps, response.json['steps'])
def test_update_by_name_with_json(self, mock_save):
interface = 'bios'
path = '/deploy_templates/%s.json' % self.template.name
response = self.patch_json(path,
[{'path': '/steps/0/interface',
'value': interface,
'op': 'replace'}],
headers=self.headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code)
self.assertEqual(interface, response.json['steps'][0]['interface'])
def test_update_name_standard_trait(self, mock_save):
name = 'HW_CPU_X86_VMX'
patch = [{'path': '/name', 'value': name, 'op': 'replace'}]
self._test_update_ok(mock_save, patch)
def test_update_invalid_name(self, mock_save):
self._test_update_bad_request(
mock_save,
[{'path': '/name', 'value': 'aa:bb_cc', 'op': 'replace'}],
'Deploy template name must be a valid trait')
def test_update_by_id_invalid_api_version(self, mock_save):
name = 'CUSTOM_DT2'
headers = self.invalid_version_headers
response = self.patch_json('/deploy_templates/%s' % self.template.uuid,
[{'path': '/name',
'value': name,
'op': 'add'}],
headers=headers,
expect_errors=True)
self.assertEqual(http_client.NOT_FOUND, response.status_int)
self.assertFalse(mock_save.called)
def test_update_not_found(self, mock_save):
name = 'CUSTOM_DT2'
uuid = uuidutils.generate_uuid()
response = self.patch_json('/deploy_templates/%s' % uuid,
[{'path': '/name',
'value': name,
'op': 'add'}],
expect_errors=True,
headers=self.headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.NOT_FOUND, response.status_int)
self.assertTrue(response.json['error_message'])
self.assertFalse(mock_save.called)
def test_replace_singular(self, mock_save):
name = 'CUSTOM_DT2'
patch = [{'path': '/name', 'value': name, 'op': 'replace'}]
response = self._test_update_ok(mock_save, patch)
self.assertEqual(name, response.json['name'])
@mock.patch.object(notification_utils, '_emit_api_notification',
autospec=True)
def test_replace_name_already_exist(self, mock_notify, mock_save):
name = 'CUSTOM_DT2'
obj_utils.create_test_deploy_template(self.context,
uuid=uuidutils.generate_uuid(),
name=name)
mock_save.side_effect = exception.DeployTemplateAlreadyExists(
uuid=self.template.uuid)
response = self.patch_json('/deploy_templates/%s' % self.template.uuid,
[{'path': '/name',
'value': name,
'op': 'replace'}],
expect_errors=True,
headers=self.headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.CONFLICT, response.status_code)
self.assertTrue(response.json['error_message'])
mock_save.assert_called_once_with(mock.ANY)
mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update',
obj_fields.NotificationLevel.INFO,
obj_fields.NotificationStatus.START),
mock.call(mock.ANY, mock.ANY, 'update',
obj_fields.NotificationLevel.ERROR,
obj_fields.NotificationStatus.ERROR)])
def test_replace_invalid_name_too_long(self, mock_save):
name = 'CUSTOM_' + 'X' * 249
patch = [{'path': '/name', 'op': 'replace', 'value': name}]
self._test_update_bad_request(
mock_save, patch, 'Deploy template name must be a valid trait')
def test_replace_invalid_name_not_a_trait(self, mock_save):
name = 'not-a-trait'
patch = [{'path': '/name', 'op': 'replace', 'value': name}]
self._test_update_bad_request(
mock_save, patch, 'Deploy template name must be a valid trait')
def test_replace_invalid_name_none(self, mock_save):
patch = [{'path': '/name', 'op': 'replace', 'value': None}]
self._test_update_bad_request(
mock_save, patch, "Deploy template name cannot be None")
def test_replace_duplicate_step(self, mock_save):
# interface & step combination must be unique.
steps = [
{
'interface': 'raid',
'step': 'create_configuration',
'args': {'foo': '%d' % i},
'priority': i,
}
for i in range(2)
]
patch = [{'path': '/steps', 'op': 'replace', 'value': steps}]
self._test_update_bad_request(
mock_save, patch, "Duplicate deploy steps")
def test_replace_invalid_step_interface_fail(self, mock_save):
step = {
'interface': 'foo',
'step': 'apply_configuration',
'args': {'foo': 'bar'},
'priority': 42
}
patch = [{'path': '/steps/0', 'op': 'replace', 'value': step}]
self._test_update_bad_request(
mock_save, patch, "Invalid input for field/attribute interface.")
def test_replace_non_existent_step_fail(self, mock_save):
step = {
'interface': 'bios',
'step': 'apply_configuration',
'args': {'foo': 'bar'},
'priority': 42
}
patch = [{'path': '/steps/1', 'op': 'replace', 'value': step}]
self._test_update_bad_request(
mock_save, patch, "list assignment index out of range")
def test_replace_empty_step_list_fail(self, mock_save):
patch = [{'path': '/steps', 'op': 'replace', 'value': []}]
self._test_update_bad_request(
mock_save, patch, 'No deploy steps specified')
def _test_remove_not_allowed(self, mock_save, field, error_msg):
patch = [{'path': '/%s' % field, 'op': 'remove'}]
self._test_update_bad_request(mock_save, patch, error_msg)
def test_remove_uuid(self, mock_save):
self._test_remove_not_allowed(
mock_save, 'uuid',
"'/uuid' is an internal attribute and can not be updated")
def test_remove_name(self, mock_save):
self._test_remove_not_allowed(
mock_save, 'name',
"'/name' is a mandatory attribute and can not be removed")
def test_remove_steps(self, mock_save):
self._test_remove_not_allowed(
mock_save, 'steps',
"'/steps' is a mandatory attribute and can not be removed")
def test_remove_foo(self, mock_save):
self._test_remove_not_allowed(
mock_save, 'foo', "can't remove non-existent object 'foo'")
def test_replace_step_invalid_interface(self, mock_save):
patch = [{'path': '/steps/0/interface', 'op': 'replace',
'value': 'foo'}]
self._test_update_bad_request(
mock_save, patch, "Invalid input for field/attribute interface.")
def test_replace_multi(self, mock_save):
steps = [
{
'interface': 'raid',
'step': 'create_configuration%d' % i,
'args': {},
'priority': 10,
}
for i in range(3)
]
template = obj_utils.create_test_deploy_template(
self.context, uuid=uuidutils.generate_uuid(), name='CUSTOM_DT2',
steps=steps)
# mutate steps so we replace all of them
for step in steps:
step['priority'] = step['priority'] + 1
patch = []
for i, step in enumerate(steps):
patch.append({'path': '/steps/%s' % i,
'value': steps[i],
'op': 'replace'})
response = self.patch_json('/deploy_templates/%s' % template.uuid,
patch, headers=self.headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code)
self.assertEqual(steps, response.json['steps'])
mock_save.assert_called_once_with(mock.ANY)
def test_remove_multi(self, mock_save):
steps = [
{
'interface': 'raid',
'step': 'create_configuration%d' % i,
'args': {},
'priority': 10,
}
for i in range(3)
]
template = obj_utils.create_test_deploy_template(
self.context, uuid=uuidutils.generate_uuid(), name='CUSTOM_DT2',
steps=steps)
# Removing one step from the collection
steps.pop(1)
response = self.patch_json('/deploy_templates/%s' % template.uuid,
[{'path': '/steps/1',
'op': 'remove'}],
headers=self.headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code)
self.assertEqual(steps, response.json['steps'])
mock_save.assert_called_once_with(mock.ANY)
def test_remove_non_existent_property_fail(self, mock_save):
patch = [{'path': '/non-existent', 'op': 'remove'}]
self._test_update_bad_request(
mock_save, patch,
"can't remove non-existent object 'non-existent'")
def test_remove_non_existent_step_fail(self, mock_save):
patch = [{'path': '/steps/1', 'op': 'remove'}]
self._test_update_bad_request(
mock_save, patch, "can't remove non-existent object '1'")
def test_remove_only_step_fail(self, mock_save):
patch = [{'path': '/steps/0', 'op': 'remove'}]
self._test_update_bad_request(
mock_save, patch, "No deploy steps specified")
def test_remove_non_existent_step_property_fail(self, mock_save):
patch = [{'path': '/steps/0/non-existent', 'op': 'remove'}]
self._test_update_bad_request(
mock_save, patch,
"can't remove non-existent object 'non-existent'")
def test_add_root_non_existent(self, mock_save):
patch = [{'path': '/foo', 'value': 'bar', 'op': 'add'}]
self._test_update_bad_request(
mock_save, patch, "Adding a new attribute (/foo)")
def test_add_too_high_index_step_fail(self, mock_save):
step = {
'interface': 'bios',
'step': 'apply_configuration',
'args': {'foo': 'bar'},
'priority': 42
}
patch = [{'path': '/steps/2', 'op': 'add', 'value': step}]
self._test_update_bad_request(
mock_save, patch, "can't insert outside of list")
def test_add_multi(self, mock_save):
steps = [
{
'interface': 'raid',
'step': 'create_configuration%d' % i,
'args': {},
'priority': 10,
}
for i in range(3)
]
patch = []
for i, step in enumerate(steps):
patch.append({'path': '/steps/%d' % i,
'value': step,
'op': 'add'})
response = self.patch_json('/deploy_templates/%s' % self.template.uuid,
patch, headers=self.headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code)
self.assertEqual(steps, response.json['steps'][:-1])
self.assertEqual(_obj_to_api_step(self.template.steps[0]),
response.json['steps'][-1])
mock_save.assert_called_once_with(mock.ANY)
class TestPost(BaseDeployTemplatesAPITest):
@mock.patch.object(notification_utils, '_emit_api_notification',
autospec=True)
@mock.patch.object(timeutils, 'utcnow', autospec=True)
def test_create(self, mock_utcnow, mock_notify):
tdict = test_api_utils.post_get_test_deploy_template()
test_time = datetime.datetime(2000, 1, 1, 0, 0)
mock_utcnow.return_value = test_time
response = self.post_json('/deploy_templates', tdict,
headers=self.headers)
self.assertEqual(http_client.CREATED, response.status_int)
result = self.get_json('/deploy_templates/%s' % tdict['uuid'],
headers=self.headers)
self.assertEqual(tdict['uuid'], result['uuid'])
self.assertFalse(result['updated_at'])
return_created_at = timeutils.parse_isotime(
result['created_at']).replace(tzinfo=None)
self.assertEqual(test_time, return_created_at)
# Check location header
self.assertIsNotNone(response.location)
expected_location = '/v1/deploy_templates/%s' % tdict['uuid']
self.assertEqual(expected_location,
urlparse.urlparse(response.location).path)
mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'create',
obj_fields.NotificationLevel.INFO,
obj_fields.NotificationStatus.START),
mock.call(mock.ANY, mock.ANY, 'create',
obj_fields.NotificationLevel.INFO,
obj_fields.NotificationStatus.END)])
def test_create_invalid_api_version(self):
tdict = test_api_utils.post_get_test_deploy_template()
response = self.post_json(
'/deploy_templates', tdict, headers=self.invalid_version_headers,
expect_errors=True)
self.assertEqual(http_client.NOT_FOUND, response.status_int)
def test_create_doesnt_contain_id(self):
with mock.patch.object(
self.dbapi, 'create_deploy_template',
wraps=self.dbapi.create_deploy_template) as mock_create:
tdict = test_api_utils.post_get_test_deploy_template()
self.post_json('/deploy_templates', tdict, headers=self.headers)
self.get_json('/deploy_templates/%s' % tdict['uuid'],
headers=self.headers)
mock_create.assert_called_once_with(mock.ANY)
# Check that 'id' is not in first arg of positional args
self.assertNotIn('id', mock_create.call_args[0][0])
@mock.patch.object(notification_utils.LOG, 'exception', autospec=True)
@mock.patch.object(notification_utils.LOG, 'warning', autospec=True)
def test_create_generate_uuid(self, mock_warn, mock_except):
tdict = test_api_utils.post_get_test_deploy_template()
del tdict['uuid']
response = self.post_json('/deploy_templates', tdict,
headers=self.headers)
result = self.get_json('/deploy_templates/%s' % response.json['uuid'],
headers=self.headers)
self.assertTrue(uuidutils.is_uuid_like(result['uuid']))
self.assertFalse(mock_warn.called)
self.assertFalse(mock_except.called)
@mock.patch.object(notification_utils, '_emit_api_notification',
autospec=True)
@mock.patch.object(objects.DeployTemplate, 'create', autospec=True)
def test_create_error(self, mock_create, mock_notify):
mock_create.side_effect = Exception()
tdict = test_api_utils.post_get_test_deploy_template()
self.post_json('/deploy_templates', tdict, headers=self.headers,
expect_errors=True)
mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'create',
obj_fields.NotificationLevel.INFO,
obj_fields.NotificationStatus.START),
mock.call(mock.ANY, mock.ANY, 'create',
obj_fields.NotificationLevel.ERROR,
obj_fields.NotificationStatus.ERROR)])
def _test_create_ok(self, tdict):
response = self.post_json('/deploy_templates', tdict,
headers=self.headers)
self.assertEqual(http_client.CREATED, response.status_int)
def _test_create_bad_request(self, tdict, error_msg):
response = self.post_json('/deploy_templates', tdict,
expect_errors=True, headers=self.headers)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])
self.assertIn(error_msg, response.json['error_message'])
def test_create_long_name(self):
name = 'CUSTOM_' + 'X' * 248
tdict = test_api_utils.post_get_test_deploy_template(name=name)
self._test_create_ok(tdict)
def test_create_standard_trait_name(self):
name = 'HW_CPU_X86_VMX'
tdict = test_api_utils.post_get_test_deploy_template(name=name)
self._test_create_ok(tdict)
def test_create_name_invalid_too_long(self):
name = 'CUSTOM_' + 'X' * 249
tdict = test_api_utils.post_get_test_deploy_template(name=name)
self._test_create_bad_request(
tdict, 'Deploy template name must be a valid trait')
def test_create_name_invalid_not_a_trait(self):
name = 'not-a-trait'
tdict = test_api_utils.post_get_test_deploy_template(name=name)
self._test_create_bad_request(
tdict, 'Deploy template name must be a valid trait')
def test_create_steps_invalid_duplicate(self):
steps = [
{
'interface': 'raid',
'step': 'create_configuration',
'args': {'foo': '%d' % i},
'priority': i,
}
for i in range(2)
]
tdict = test_api_utils.post_get_test_deploy_template(steps=steps)
self._test_create_bad_request(tdict, "Duplicate deploy steps")
def _test_create_no_mandatory_field(self, field):
tdict = test_api_utils.post_get_test_deploy_template()
del tdict[field]
self._test_create_bad_request(tdict, "Mandatory field missing")
def test_create_no_mandatory_field_name(self):
self._test_create_no_mandatory_field('name')
def test_create_no_mandatory_field_steps(self):
self._test_create_no_mandatory_field('steps')
def _test_create_no_mandatory_step_field(self, field):
tdict = test_api_utils.post_get_test_deploy_template()
del tdict['steps'][0][field]
self._test_create_bad_request(tdict, "Mandatory field missing")
def test_create_no_mandatory_step_field_interface(self):
self._test_create_no_mandatory_step_field('interface')
def test_create_no_mandatory_step_field_step(self):
self._test_create_no_mandatory_step_field('step')
def test_create_no_mandatory_step_field_args(self):
self._test_create_no_mandatory_step_field('args')
def test_create_no_mandatory_step_field_priority(self):
self._test_create_no_mandatory_step_field('priority')
def _test_create_invalid_field(self, field, value, error_msg):
tdict = test_api_utils.post_get_test_deploy_template()
tdict[field] = value
self._test_create_bad_request(tdict, error_msg)
def test_create_invalid_field_name(self):
self._test_create_invalid_field(
'name', 42, 'Invalid input for field/attribute name')
def test_create_invalid_field_name_none(self):
self._test_create_invalid_field(
'name', None, "Deploy template name cannot be None")
def test_create_invalid_field_steps(self):
self._test_create_invalid_field(
'steps', {}, "Invalid input for field/attribute template")
def test_create_invalid_field_empty_steps(self):
self._test_create_invalid_field(
'steps', [], "No deploy steps specified")
def test_create_invalid_field_extra(self):
self._test_create_invalid_field(
'extra', 42, "Invalid input for field/attribute template")
def test_create_invalid_field_foo(self):
self._test_create_invalid_field(
'foo', 'bar', "Unknown attribute for argument template: foo")
def _test_create_invalid_step_field(self, field, value, error_msg=None):
tdict = test_api_utils.post_get_test_deploy_template()
tdict['steps'][0][field] = value
if error_msg is None:
error_msg = "Invalid input for field/attribute"
self._test_create_bad_request(tdict, error_msg)
def test_create_invalid_step_field_interface1(self):
self._test_create_invalid_step_field('interface', [3])
def test_create_invalid_step_field_interface2(self):
self._test_create_invalid_step_field('interface', 'foo')
def test_create_invalid_step_field_step(self):
self._test_create_invalid_step_field('step', 42)
def test_create_invalid_step_field_args1(self):
self._test_create_invalid_step_field('args', 'not a dict')
def test_create_invalid_step_field_args2(self):
self._test_create_invalid_step_field('args', [])
def test_create_invalid_step_field_priority(self):
self._test_create_invalid_step_field('priority', 'not a number')
def test_create_invalid_step_field_negative_priority(self):
self._test_create_invalid_step_field('priority', -1)
def test_create_invalid_step_field_foo(self):
self._test_create_invalid_step_field(
'foo', 'bar', "Unknown attribute for argument template.steps: foo")
def test_create_step_string_priority(self):
tdict = test_api_utils.post_get_test_deploy_template()
tdict['steps'][0]['priority'] = '42'
self._test_create_ok(tdict)
def test_create_complex_step_args(self):
tdict = test_api_utils.post_get_test_deploy_template()
tdict['steps'][0]['args'] = {'foo': [{'bar': 'baz'}]}
self._test_create_ok(tdict)
@mock.patch.object(objects.DeployTemplate, 'destroy', autospec=True)
class TestDelete(BaseDeployTemplatesAPITest):
def setUp(self):
super(TestDelete, self).setUp()
self.template = obj_utils.create_test_deploy_template(self.context)
@mock.patch.object(notification_utils, '_emit_api_notification',
autospec=True)
def test_delete_by_uuid(self, mock_notify, mock_destroy):
self.delete('/deploy_templates/%s' % self.template.uuid,
headers=self.headers)
mock_destroy.assert_called_once_with(mock.ANY)
mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete',
obj_fields.NotificationLevel.INFO,
obj_fields.NotificationStatus.START),
mock.call(mock.ANY, mock.ANY, 'delete',
obj_fields.NotificationLevel.INFO,
obj_fields.NotificationStatus.END)])
def test_delete_by_uuid_with_json(self, mock_destroy):
self.delete('/deploy_templates/%s.json' % self.template.uuid,
headers=self.headers)
mock_destroy.assert_called_once_with(mock.ANY)
def test_delete_by_name(self, mock_destroy):
self.delete('/deploy_templates/%s' % self.template.name,
headers=self.headers)
mock_destroy.assert_called_once_with(mock.ANY)
def test_delete_by_name_with_json(self, mock_destroy):
self.delete('/deploy_templates/%s.json' % self.template.name,
headers=self.headers)
mock_destroy.assert_called_once_with(mock.ANY)
def test_delete_invalid_api_version(self, mock_dpt):
response = self.delete('/deploy_templates/%s' % self.template.uuid,
expect_errors=True,
headers=self.invalid_version_headers)
self.assertEqual(http_client.NOT_FOUND, response.status_int)
def test_delete_by_name_non_existent(self, mock_dpt):
res = self.delete('/deploy_templates/%s' % 'blah', expect_errors=True,
headers=self.headers)
self.assertEqual(http_client.NOT_FOUND, res.status_code)

View File

@ -26,6 +26,7 @@ import wsme
from ironic.api.controllers.v1 import node as api_node
from ironic.api.controllers.v1 import utils
from ironic.common import exception
from ironic.common import policy
from ironic.common import states
from ironic import objects
from ironic.tests import base
@ -80,6 +81,10 @@ class TestApiUtils(base.TestCase):
utils.validate_trait(large)
self.assertRaises(wsme.exc.ClientSideError,
utils.validate_trait, large + "1")
# Check custom error prefix.
self.assertRaisesRegex(wsme.exc.ClientSideError,
"spongebob",
utils.validate_trait, "invalid", "spongebob")
def test_get_patch_values_no_path(self):
patch = [{'path': '/name', 'op': 'update', 'value': 'node-0'}]
@ -530,6 +535,13 @@ class TestApiUtils(base.TestCase):
mock_request.version.minor = 52
self.assertFalse(utils.allow_port_is_smartnic())
@mock.patch.object(pecan, 'request', spec_set=['version'])
def test_allow_deploy_templates(self, mock_request):
mock_request.version.minor = 55
self.assertTrue(utils.allow_deploy_templates())
mock_request.version.minor = 54
self.assertFalse(utils.allow_deploy_templates())
class TestNodeIdent(base.TestCase):
@ -717,6 +729,20 @@ class TestVendorPassthru(base.TestCase):
sorted(utils.get_controller_reserved_names(
api_node.NodesController)))
@mock.patch.object(pecan, 'request', spec_set=["context"])
@mock.patch.object(policy, 'authorize', spec=True)
def test_check_policy(self, mock_authorize, mock_pr):
utils.check_policy('fake-policy')
cdict = pecan.request.context.to_policy_values()
mock_authorize.assert_called_once_with('fake-policy', cdict, cdict)
@mock.patch.object(pecan, 'request', spec_set=["context"])
@mock.patch.object(policy, 'authorize', spec=True)
def test_check_policy_forbidden(self, mock_authorize, mock_pr):
mock_authorize.side_effect = exception.HTTPForbidden(resource='fake')
self.assertRaises(exception.HTTPForbidden,
utils.check_policy, 'fake-policy')
class TestPortgroupIdent(base.TestCase):
def setUp(self):

View File

@ -20,9 +20,11 @@ import hashlib
import json
from ironic.api.controllers.v1 import chassis as chassis_controller
from ironic.api.controllers.v1 import deploy_template as dt_controller
from ironic.api.controllers.v1 import node as node_controller
from ironic.api.controllers.v1 import port as port_controller
from ironic.api.controllers.v1 import portgroup as portgroup_controller
from ironic.api.controllers.v1 import types
from ironic.api.controllers.v1 import utils as api_utils
from ironic.api.controllers.v1 import volume_connector as vc_controller
from ironic.api.controllers.v1 import volume_target as vt_controller
@ -200,3 +202,24 @@ def allocation_post_data(**kw):
def fake_event_validator(v):
"""A fake event validator"""
return v
def deploy_template_post_data(**kw):
"""Return a DeployTemplate object without internal attributes."""
template = db_utils.get_test_deploy_template(**kw)
# These values are not part of the API object
template.pop('version')
# Remove internal attributes from each step.
step_internal = types.JsonPatchType.internal_attrs()
step_internal.append('deploy_template_id')
template['steps'] = [remove_internal(step, step_internal)
for step in template['steps']]
# Remove internal attributes from the template.
dt_patch = dt_controller.DeployTemplatePatchType
internal = dt_patch.internal_attrs()
return remove_internal(template, internal)
def post_get_test_deploy_template(**kw):
"""Return a DeployTemplate object with appropriate attributes."""
return deploy_template_post_data(**kw)

View File

@ -718,6 +718,8 @@ expected_object_fingerprints = {
'AllocationCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
'AllocationCRUDPayload': '1.0-a82389d019f37cfe54b50049f73911b3',
'DeployTemplate': '1.1-4e30c8e9098595e359bb907f095bf1a9',
'DeployTemplateCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
'DeployTemplateCRUDPayload': '1.0-200857e7e715f58a5b6d6b700ab73a3b',
}

View File

@ -163,4 +163,4 @@ webcolors==1.8.1
WebOb==1.7.1
WebTest==2.0.27
wrapt==1.10.11
WSME==0.8.0
WSME==0.9.3

View File

@ -0,0 +1,15 @@
---
features:
- |
Adds the deploy templates API. Deploy templates can be used to customise
the node deployment process, each specifying a list of deploy steps to
execute with configurable priority and arguments.
Introduces the following new API endpoints, available from Bare Metal REST
API version 1.55:
* ``GET /v1/deploy_templates``
* ``GET /v1/deploy_templates/<deploy template identifier>``
* ``POST /v1/deploy_templates``
* ``PATCH /v1/deploy_templates/<deploy template identifier>``
* ``DELETE /v1/deploy_templates/<deploy template identifier>``

View File

@ -37,7 +37,7 @@ requests>=2.14.2 # Apache-2.0
rfc3986>=0.3.1 # Apache-2.0
six>=1.10.0 # MIT
jsonpatch!=1.20,>=1.16 # BSD
WSME>=0.8.0 # MIT
WSME>=0.9.3 # MIT
Jinja2>=2.10 # BSD License (3 clause)
keystonemiddleware>=4.17.0 # Apache-2.0
oslo.messaging>=5.29.0 # Apache-2.0