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}]
|
||||
< |