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:
parent
17a944fe9d
commit
ec2f7f992e
@ -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)
|
||||
--------------------
|
||||
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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):
|
||||
|
446
ironic/api/controllers/v1/deploy_template.py
Normal file
446
ironic/api/controllers/v1/deploy_template.py
Normal 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')
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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.")
|
||||
|
@ -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
|
||||
|
||||
|
@ -131,7 +131,7 @@ RELEASE_MAPPING = {
|
||||
}
|
||||
},
|
||||
'master': {
|
||||
'api': '1.54',
|
||||
'api': '1.55',
|
||||
'rpc': '1.48',
|
||||
'objects': {
|
||||
'Allocation': ['1.0'],
|
||||
|
@ -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)
|
||||
|
942
ironic/tests/unit/api/controllers/v1/test_deploy_template.py
Normal file
942
ironic/tests/unit/api/controllers/v1/test_deploy_template.py
Normal 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)
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
}
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
15
releasenotes/notes/deploy-templates-5df3368df862631c.yaml
Normal file
15
releasenotes/notes/deploy-templates-5df3368df862631c.yaml
Normal 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>``
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user