diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst index bb693f74ce..6d47696f82 100644 --- a/doc/source/contributor/webapi-version-history.rst +++ b/doc/source/contributor/webapi-version-history.rst @@ -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/`` to retrieve details + of a deploy template. +* ``POST /v1/deploy_templates`` to create a deploy template. +* ``PATCH /v1/deploy_templates/`` to update a + deploy template. +* ``DELETE /v1/deploy_templates/`` to delete a + deploy template. + 1.54 (Stein, master) -------------------- diff --git a/ironic/api/controllers/base.py b/ironic/api/controllers/base.py index 89a9de0e36..41ddf31e59 100644 --- a/ironic/api/controllers/base.py +++ b/ironic/api/controllers/base.py @@ -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. diff --git a/ironic/api/controllers/v1/__init__.py b/ironic/api/controllers/v1/__init__.py index 6dc71c3e01..322337e925 100644 --- a/ironic/api/controllers/v1/__init__.py +++ b/ironic/api/controllers/v1/__init__.py @@ -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): diff --git a/ironic/api/controllers/v1/deploy_template.py b/ironic/api/controllers/v1/deploy_template.py new file mode 100644 index 0000000000..2f1f67a75c --- /dev/null +++ b/ironic/api/controllers/v1/deploy_template.py @@ -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') diff --git a/ironic/api/controllers/v1/notification_utils.py b/ironic/api/controllers/v1/notification_utils.py index 31464e02be..c81072c657 100644 --- a/ironic/api/controllers/v1/notification_utils.py +++ b/ironic/api/controllers/v1/notification_utils.py @@ -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, diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 9846179a2b..f75cf56171 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -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) diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 8fe33c7c6a..5298d789c3 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -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) diff --git a/ironic/common/exception.py b/ironic/common/exception.py index e5630990c5..c6211ed9a8 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -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.") diff --git a/ironic/common/policy.py b/ironic/common/policy.py index 3845580b83..9715aba0f7 100644 --- a/ironic/common/policy.py +++ b/ironic/common/policy.py @@ -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 diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index 6fd6978ab1..73ff0b683e 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -131,7 +131,7 @@ RELEASE_MAPPING = { } }, 'master': { - 'api': '1.54', + 'api': '1.55', 'rpc': '1.48', 'objects': { 'Allocation': ['1.0'], diff --git a/ironic/objects/deploy_template.py b/ironic/objects/deploy_template.py index 47c3ebfa5a..a9c8a60fe0 100644 --- a/ironic/objects/deploy_template.py +++ b/ironic/objects/deploy_template.py @@ -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) diff --git a/ironic/tests/unit/api/controllers/v1/test_deploy_template.py b/ironic/tests/unit/api/controllers/v1/test_deploy_template.py new file mode 100644 index 0000000000..2c2bbebb11 --- /dev/null +++ b/ironic/tests/unit/api/controllers/v1/test_deploy_template.py @@ -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) diff --git a/ironic/tests/unit/api/controllers/v1/test_utils.py b/ironic/tests/unit/api/controllers/v1/test_utils.py index 7b6411feb3..d456ea10f8 100644 --- a/ironic/tests/unit/api/controllers/v1/test_utils.py +++ b/ironic/tests/unit/api/controllers/v1/test_utils.py @@ -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): diff --git a/ironic/tests/unit/api/utils.py b/ironic/tests/unit/api/utils.py index 36321154bb..802e6c45cd 100644 --- a/ironic/tests/unit/api/utils.py +++ b/ironic/tests/unit/api/utils.py @@ -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) diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index 0717cc42df..865b597d6c 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -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', } diff --git a/lower-constraints.txt b/lower-constraints.txt index 82846cf2cd..aedaa737f5 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -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 diff --git a/releasenotes/notes/deploy-templates-5df3368df862631c.yaml b/releasenotes/notes/deploy-templates-5df3368df862631c.yaml new file mode 100644 index 0000000000..56c44307ac --- /dev/null +++ b/releasenotes/notes/deploy-templates-5df3368df862631c.yaml @@ -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/`` + * ``POST /v1/deploy_templates`` + * ``PATCH /v1/deploy_templates/`` + * ``DELETE /v1/deploy_templates/`` diff --git a/requirements.txt b/requirements.txt index e339ead1b8..544a2a75b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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