diff --git a/api-ref/source/baremetal-api-v1-runbooks.inc b/api-ref/source/baremetal-api-v1-runbooks.inc new file mode 100644 index 0000000000..5e4392c4ab --- /dev/null +++ b/api-ref/source/baremetal-api-v1-runbooks.inc @@ -0,0 +1,245 @@ +.. -*- rst -*- + +=================== +Runbooks (runbooks) +=================== + +The Runbook resource represents a collection of steps that define a +series of actions to be executed on a node. Runbooks enable users to perform +complex operations in a predefined, automated manner. A runbook is +matched for a node if the runbook's name matches a trait in the node. + +.. versionadded:: 1.92 + Runbook API was introduced. + +Create Runbook +============== + +.. rest_method:: POST /v1/runbooks + +Creates a runbook. + +.. versionadded:: 1.92 + Runbook API was introduced. + +Normal response codes: 201 + +Error response codes: 400, 401, 403, 409 + +Request +------- + +.. rest_parameters:: parameters.yaml + + - name: runbook_name + - steps: runbook_steps + - disable_ramdisk: req_disable_ramdisk + - uuid: req_uuid + - extra: req_extra + +Request Step +------------ + +.. rest_parameters:: parameters.yaml + + - interface: runbook_step_interface + - step: runbook_step_step + - args: runbook_step_args + - order: runbook_step_order + +Request Example +--------------- + +.. literalinclude:: samples/runbook-create-request.json + :language: javascript + +Response Parameters +------------------- + +.. rest_parameters:: parameters.yaml + + - uuid: uuid + - name: runbook_name + - steps: runbook_steps + - disable_ramdisk: disable_ramdisk + - extra: extra + - public: runbook_public + - owner: runbook_owner + - created_at: created_at + - updated_at: updated_at + - links: links + +Response Example +---------------- + +.. literalinclude:: samples/runbook-create-response.json + :language: javascript + +List Runbooks +============= + +.. rest_method:: GET /v1/runbooks + +Lists all runbooks. + +.. versionadded:: 1.92 + Runbook API was introduced. + +Normal response codes: 200 + +Error response codes: 400, 401, 403, 404 + +Request +------- + +.. rest_parameters:: parameters.yaml + + - fields: fields + - limit: limit + - marker: marker + - sort_dir: sort_dir + - sort_key: sort_key + - detail: detail + +Response Parameters +------------------- + +.. rest_parameters:: parameters.yaml + + - uuid: uuid + - name: runbook_name + - disable_ramdisk: disable_ramdisk + - steps: runbook_steps + - extra: extra + - public: runbook_public + - owner: runbook_owner + - created_at: created_at + - updated_at: updated_at + - links: links + +Response Example +---------------- + +**Example runbook list response:** + +.. literalinclude:: samples/runbook-list-response.json + :language: javascript + +**Example detailed runbook list response:** + +.. literalinclude:: samples/runbook-detail-response.json + :language: javascript + +Show Runbook Details +==================== + +.. rest_method:: GET /v1/runbooks/{runbook_id} + +Shows details for a runbook. + +.. versionadded:: 1.92 + Runbook API was introduced. + +Normal response codes: 200 + +Error response codes: 400, 401, 403, 404 + +Request +------- + +.. rest_parameters:: parameters.yaml + + - fields: fields + - runbook_id: runbook_ident + +Response Parameters +------------------- + +.. rest_parameters:: parameters.yaml + + - uuid: uuid + - name: runbook_name + - steps: runbook_steps + - disable_ramdisk: disable_ramdisk + - extra: extra + - public: runbook_public + - owner: runbook_owner + - created_at: created_at + - updated_at: updated_at + - links: links + +Response Example +---------------- + +.. literalinclude:: samples/runbook-show-response.json + :language: javascript + +Update a Runbook +================ + +.. rest_method:: PATCH /v1/runbooks/{runbook_id} + +Update a runbook. + +.. versionadded:: 1.92 + Runbook API was introduced. + +Normal response code: 200 + +Error response codes: 400, 401, 403, 404, 409 + +Request +------- + +The BODY of the PATCH request must be a JSON PATCH document, adhering to +`RFC 6902 `_. + +Request +------- + +.. rest_parameters:: parameters.yaml + + - runbook_id: runbook_ident + +.. literalinclude:: samples/runbook-update-request.json + :language: javascript + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - uuid: uuid + - name: runbook_name + - steps: runbook_steps + - disable_ramdisk: disable_ramdisk + - extra: extra + - public: runbook_public + - owner: runbook_owner + - created_at: created_at + - updated_at: updated_at + - links: links + +.. literalinclude:: samples/runbook-update-response.json + :language: javascript + +Delete Runbook +============== + +.. rest_method:: DELETE /v1/runbooks/{runbook_id} + +Deletes a runbook. + +.. versionadded:: 1.92 + Runbook API was introduced. + +Normal response codes: 204 + +Error response codes: 400, 401, 403, 404 + +Request +------- + +.. rest_parameters:: parameters.yaml + + - runbook_id: runbook_ident diff --git a/doc/source/admin/cleaning.rst b/doc/source/admin/cleaning.rst index bbfa1ac36c..16030bda78 100644 --- a/doc/source/admin/cleaning.rst +++ b/doc/source/admin/cleaning.rst @@ -209,6 +209,15 @@ In the above example, the node's RAID interface would configure hardware RAID without non-root volumes, and then all devices would be erased (in that order). +Alternatively, you can specify a runbook instead of clean_steps:: + + { + "target":"clean", + "runbook": "" + } + +The specified runbook must match one of the node's traits to be used. + Starting manual cleaning via "openstack metal" CLI ------------------------------------------------------ @@ -246,6 +255,24 @@ Or with stdin:: cat my-clean-steps.txt | baremetal node clean \ --clean-steps - +To use a runbook instead of specifying clean steps: + + baremetal node clean --runbook + +Runbooks for Manual Cleaning +---------------------------- +Instead of passing a list of clean steps, operators can now use runbooks. +Runbooks are curated lists of steps that can be associated with nodes via +traits which simplifies the process of performing consistent cleaning +operations across similar nodes. + +To use a runbook for manual cleaning: + + baremetal node clean --runbook + +Runbooks must be created and associated with nodes beforehand. Only runbooks +that match the node's traits can be used for cleaning that node. + Cleaning Network ================ diff --git a/doc/source/admin/servicing.rst b/doc/source/admin/servicing.rst index 55eb85f161..69c2c17070 100644 --- a/doc/source/admin/servicing.rst +++ b/doc/source/admin/servicing.rst @@ -109,6 +109,15 @@ configuration, and then the vendor interface's ``send_raw`` step would be called to send a raw command to the BMC. Please note, ``send_raw`` is only available for the ``ipmi`` hardware type. +Alternatively, you can specify a runbook instead of service_steps:: + + { + "target":"service", + "runbook": "" + } + +The specified runbook must match one of the node's traits to be used. + Starting servicing via "openstack baremetal" CLI ------------------------------------------------ @@ -137,6 +146,23 @@ Or with stdin:: cat my-clean-steps.txt | baremetal node service \ --service-steps - +To use a runbook instead of specifying service steps: + + baremetal node service --runbook + +Using Runbooks for Servicing +---------------------------- +Similar to manual cleaning, you can use runbooks for node servicing. +Runbooks provide a predefined list of service steps associated with nodes +via traits. + +To use a runbook for servicing: + + baremetal node service --runbook + +Ensure that the runbook matches one of the node's traits before using it +for servicing. + Available Steps in Ironic ------------------------- diff --git a/ironic/api/controllers/v1/__init__.py b/ironic/api/controllers/v1/__init__.py index e0b2200f7f..edf6154998 100644 --- a/ironic/api/controllers/v1/__init__.py +++ b/ironic/api/controllers/v1/__init__.py @@ -36,6 +36,7 @@ from ironic.api.controllers.v1 import node from ironic.api.controllers.v1 import port from ironic.api.controllers.v1 import portgroup from ironic.api.controllers.v1 import ramdisk +from ironic.api.controllers.v1 import runbook from ironic.api.controllers.v1 import shard from ironic.api.controllers.v1 import utils from ironic.api.controllers.v1 import versions @@ -77,6 +78,7 @@ VERSIONED_CONTROLLERS = { 'events': utils.allow_expose_events, 'deploy_templates': utils.allow_deploy_templates, 'shards': utils.allow_shards_endpoint, + 'runbooks': utils.allow_runbooks, # NOTE(dtantsur): continue_inspection is available in 1.1 as a # compatibility hack to make it usable with IPA without changes. # Hide this fact from consumers since it was not actually available @@ -131,6 +133,7 @@ class Controller(object): 'deploy_templates': deploy_template.DeployTemplatesController(), 'shards': shard.ShardController(), 'continue_inspection': ramdisk.ContinueInspectionController(), + 'runbooks': runbook.RunbooksController() } @method.expose() diff --git a/ironic/api/controllers/v1/deploy_template.py b/ironic/api/controllers/v1/deploy_template.py index a3a800b2a8..36bc904d31 100644 --- a/ironic/api/controllers/v1/deploy_template.py +++ b/ironic/api/controllers/v1/deploy_template.py @@ -10,7 +10,6 @@ # License for the specific language governing permissions and limitations # under the License. -import collections from http import client as http_client from ironic_lib import metrics_utils @@ -57,46 +56,13 @@ PATCH_ALLOWED_FIELDS = ['extra', 'name', 'steps', 'description'] STEP_PATCH_ALLOWED_FIELDS = ['args', 'interface', 'priority', 'step'] -def duplicate_steps(name, value): - """Argument validator to check template for duplicate steps""" - # 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 - - TEMPLATE_VALIDATOR = args.and_valid( args.schema(TEMPLATE_SCHEMA), - duplicate_steps, + api_utils.duplicate_steps, args.dict_valid(uuid=args.uuid) ) -def convert_steps(rpc_steps): - for step in rpc_steps: - yield { - 'interface': step['interface'], - 'step': step['step'], - 'args': step['args'], - 'priority': step['priority'], - } - - def convert_with_links(rpc_template, fields=None, sanitize=True): """Add links to the deploy template.""" template = api_utils.object_to_dict( @@ -104,7 +70,7 @@ def convert_with_links(rpc_template, fields=None, sanitize=True): fields=('name', 'extra'), link_resource='deploy_templates', ) - template['steps'] = list(convert_steps(rpc_template.steps)) + template['steps'] = list(api_utils.convert_steps(rpc_template.steps)) if fields is not None: api_utils.check_for_invalid_fields(fields, template) diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 8ca25bdc5e..446a298035 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -86,6 +86,10 @@ _STEPS_SCHEMA = { "type": "object", "properties": {} }, + 'order': {'anyOf': [ + {'type': 'integer', 'minimum': 0}, + {'type': 'string', 'minLength': 1, 'pattern': '^[0-9]+$'} + ]}, "execute_on_child_nodes": { "description": "Boolean if the step should be executed " "on child nodes.", @@ -988,6 +992,41 @@ class NodeStatesController(rest.RestController): url_args = '/'.join([node_ident, 'states']) api.response.location = link.build_url('nodes', url_args) + def _handle_runbook(self, rpc_node, target, runbook, clean_steps, + service_steps): + if not api_utils.allow_runbooks(): + raise exception.NotAcceptable() + + rpc_runbook = api_utils.check_runbook_policy_and_retrieve( + policy_name='baremetal:runbook:use', + runbook_ident=runbook) + + node_traits = rpc_node.traits.get_trait_names() or [] + if rpc_runbook.name not in node_traits: + msg = (_('This runbook has not been approved for ' + 'use on this node %s. Please ask an administrator ' + 'to add it to your node traits.') % rpc_node.uuid) + raise exception.ClientSideError( + msg, status_code=http_client.BAD_REQUEST) + + disable_ramdisk = rpc_runbook.disable_ramdisk + if target == ir_states.VERBS['clean']: + if clean_steps: + msg = (_('Please provide either "clean_steps" or a ' + 'runbook, but not both.')) + raise exception.ClientSideError( + msg, status_code=http_client.BAD_REQUEST) + clean_steps = list(api_utils.convert_steps(rpc_runbook.steps)) + elif target == ir_states.VERBS['service']: + if service_steps: + msg = (_('Please provide either "service_steps" or a ' + 'runbook, but not both.')) + raise exception.ClientSideError( + msg, status_code=http_client.BAD_REQUEST) + service_steps = list(api_utils.convert_steps( + rpc_runbook.steps)) + return clean_steps, service_steps, disable_ramdisk + def _do_provision_action(self, rpc_node, target, configdrive=None, clean_steps=None, deploy_steps=None, rescue_password=None, disable_ramdisk=None, @@ -1061,11 +1100,12 @@ class NodeStatesController(rest.RestController): deploy_steps=args.types(type(None), list), rescue_password=args.string, disable_ramdisk=args.boolean, - service_steps=args.types(type(None), list)) + service_steps=args.types(type(None), list), + runbook=args.types(type(None), str)) def provision(self, node_ident, target, configdrive=None, clean_steps=None, deploy_steps=None, rescue_password=None, disable_ramdisk=None, - service_steps=None): + service_steps=None, runbook=None): """Asynchronous trigger the provisioning of the node. This will set the target provision state of the node, and a @@ -1142,6 +1182,7 @@ class NodeStatesController(rest.RestController): 'args': {'force': True}, 'priority': 90 } + :param runbook: UUID or logical name of a runbook. :raises: NodeLocked (HTTP 409) if the node is currently locked. :raises: ClientSideError (HTTP 409) if the node is already being provisioned. @@ -1187,9 +1228,26 @@ class NodeStatesController(rest.RestController): api_utils.check_allow_configdrive(target, configdrive) api_utils.check_allow_clean_disable_ramdisk(target, disable_ramdisk) + if runbook: + clean_steps, service_steps, disable_ramdisk = self._handle_runbook( + rpc_node, target, runbook, clean_steps, service_steps + ) + else: + if clean_steps: + api_utils.check_policy( + 'baremetal:node:set_provision_state:clean_steps') + if service_steps: + api_utils.check_policy( + 'baremetal:node:set_provision_state:service_steps') + if clean_steps and target != ir_states.VERBS['clean']: msg = (_('"clean_steps" is only valid when setting target ' 'provision state to %s') % ir_states.VERBS['clean']) + if runbook: + rb_allowed_targets = [ir_states.VERBS['clean'], + ir_states.VERBS['service']] + msg = (_('"runbooks" is only valid when setting target ' + 'provision state to any of %s') % rb_allowed_targets) raise exception.ClientSideError( msg, status_code=http_client.BAD_REQUEST) @@ -1214,6 +1272,17 @@ class NodeStatesController(rest.RestController): if not api_utils.allow_unhold_verb(): raise exception.NotAcceptable() + if service_steps and target != ir_states.VERBS['service']: + msg = (_('"service_steps" is only valid when setting target ' + 'provision state to %s') % ir_states.VERBS['service']) + if runbook: + rb_allowed_targets = [ir_states.VERBS['clean'], + ir_states.VERBS['service']] + msg = (_('"runbooks" is only valid when setting target ' + 'provision state to any of %s') % rb_allowed_targets) + raise exception.ClientSideError( + msg, status_code=http_client.BAD_REQUEST) + if target == ir_states.VERBS['service']: if not api_utils.allow_service_verb(): raise exception.NotAcceptable() diff --git a/ironic/api/controllers/v1/notification_utils.py b/ironic/api/controllers/v1/notification_utils.py index 14afbe22e7..e36b7a2cea 100644 --- a/ironic/api/controllers/v1/notification_utils.py +++ b/ironic/api/controllers/v1/notification_utils.py @@ -28,6 +28,7 @@ from ironic.objects import node as node_objects from ironic.objects import notification from ironic.objects import port as port_objects from ironic.objects import portgroup as portgroup_objects +from ironic.objects import runbook as runbook_objects from ironic.objects import volume_connector as volume_connector_objects from ironic.objects import volume_target as volume_target_objects @@ -48,6 +49,8 @@ CRUD_NOTIFY_OBJ = { port_objects.PortCRUDPayload), 'portgroup': (portgroup_objects.PortgroupCRUDNotification, portgroup_objects.PortgroupCRUDPayload), + 'runbook': (runbook_objects.RunbookCRUDNotification, + runbook_objects.RunbookCRUDPayload), 'volumeconnector': (volume_connector_objects.VolumeConnectorCRUDNotification, volume_connector_objects.VolumeConnectorCRUDPayload), diff --git a/ironic/api/controllers/v1/runbook.py b/ironic/api/controllers/v1/runbook.py new file mode 100644 index 0000000000..aab310db72 --- /dev/null +++ b/ironic/api/controllers/v1/runbook.py @@ -0,0 +1,391 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from http import client as http_client + +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 webob import exc as webob_exc + +from ironic import api +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 utils as api_utils +from ironic.api import method +from ironic.common import args +from ironic.common import exception +from ironic.common.i18n import _ +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'] + +RUNBOOK_SCHEMA = { + 'type': 'object', + 'properties': { + 'uuid': {'type': ['string', 'null']}, + 'name': api_utils.TRAITS_SCHEMA, + 'description': {'type': ['string', 'null'], 'maxLength': 255}, + 'steps': { + 'type': 'array', + 'items': api_utils.RUNBOOK_STEP_SCHEMA, + 'minItems': 1}, + 'disable_ramdisk': {'type': ['boolean', 'null']}, + 'extra': {'type': ['object', 'null']}, + 'public': {'type': ['boolean', 'null']}, + 'owner': {'type': ['string', 'null'], 'maxLength': 255} + }, + 'required': ['steps', 'name'], + 'additionalProperties': False, +} + +PATCH_ALLOWED_FIELDS = [ + 'extra', + 'name', + 'steps', + 'description', + 'public', + 'owner' +] +STEP_PATCH_ALLOWED_FIELDS = ['args', 'interface', 'order', 'step'] + + +RUNBOOK_VALIDATOR = args.and_valid( + args.schema(RUNBOOK_SCHEMA), + api_utils.duplicate_steps, + args.dict_valid(uuid=args.uuid) +) + + +def convert_with_links(rpc_runbook, fields=None, sanitize=True): + """Add links to the runbook.""" + runbook = api_utils.object_to_dict( + rpc_runbook, + fields=('name', 'extra', 'public', 'owner', 'disable_ramdisk'), + link_resource='runbooks', + ) + runbook['steps'] = list(api_utils.convert_steps(rpc_runbook.steps)) + + if fields is not None: + api_utils.check_for_invalid_fields(fields, runbook) + + if sanitize: + runbook_sanitize(runbook, fields) + + return runbook + + +def runbook_sanitize(runbook, 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 + """ + api_utils.sanitize_dict(runbook, fields) + if runbook.get('steps'): + for step in runbook['steps']: + step_sanitize(step) + + +def step_sanitize(step): + if step.get('args'): + step['args'] = strutils.mask_dict_password(step['args'], "******") + + +def list_convert_with_links(rpc_runbooks, limit, fields=None, **kwargs): + return collection.list_convert_with_links( + items=[convert_with_links(t, fields=fields, sanitize=False) + for t in rpc_runbooks], + item_name='runbooks', + url='runbooks', + limit=limit, + fields=fields, + sanitize_func=runbook_sanitize, + **kwargs + ) + + +class RunbooksController(rest.RestController): + """REST controller for runbooks.""" + + invalid_sort_key_list = ['extra', 'steps'] + + @pecan.expose() + def _route(self, args, request=None): + if not api_utils.allow_runbooks(): + msg = _("The API version does not allow runbooks") + if api.request.method == "GET": + raise webob_exc.HTTPNotFound(msg) + else: + raise webob_exc.HTTPMethodNotAllowed(msg) + return super(RunbooksController, self)._route(args, request) + + @METRICS.timer('RunbooksController.get_all') + @method.expose() + @args.validate(marker=args.name, limit=args.integer, sort_key=args.string, + sort_dir=args.string, fields=args.string_list, + detail=args.boolean, project=args.boolean) + def get_all(self, marker=None, limit=None, sort_key='id', sort_dir='asc', + fields=None, detail=None, project=None): + """Retrieve a list of runbooks. + + :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 project: Optional string value that set the project + whose runbooks are to 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 runbooks with detail. + """ + if not api_utils.allow_runbooks(): + raise exception.NotFound() + + project_id = api_utils.check_list_policy('runbook', project) + + 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}) + + filters = {} + if project_id: + filters['project'] = project_id + + marker_obj = None + if marker: + marker_obj = objects.Runbook.get_by_uuid( + api.request.context, marker) + + runbooks = objects.Runbook.list( + api.request.context, limit=limit, marker=marker_obj, + sort_key=sort_key, sort_dir=sort_dir, filters=filters) + + parameters = {'sort_key': sort_key, 'sort_dir': sort_dir} + + if detail is not None: + parameters['detail'] = detail + + return list_convert_with_links( + runbooks, limit, fields=fields, **parameters) + + @METRICS.timer('RunbooksController.get_one') + @method.expose() + @args.validate(runbook_ident=args.uuid_or_name, fields=args.string_list) + def get_one(self, runbook_ident, fields=None): + """Retrieve information about the given runbook. + + :param runbook_ident: UUID or logical name of a runbook. + :param fields: Optional, a list with a specified set of fields + of the resource to be returned. + """ + if not api_utils.allow_runbooks(): + raise exception.NotFound() + + try: + rpc_runbook = api_utils.check_runbook_policy_and_retrieve( + 'baremetal:runbook:get', runbook_ident) + except exception.NotAuthorized: + # If the user is not authorized to access the runbook, + # check also, if the runbook is public + rpc_runbook = api_utils.check_and_retrieve_public_runbook( + runbook_ident) + + api_utils.check_allowed_fields(fields) + return convert_with_links(rpc_runbook, fields=fields) + + @METRICS.timer('RunbooksController.post') + @method.expose(status_code=http_client.CREATED) + @method.body('runbook') + @args.validate(runbook=RUNBOOK_VALIDATOR) + def post(self, runbook): + """Create a new runbook. + + :param runbook: a runbook within the request body. + """ + if not api_utils.allow_runbooks(): + raise exception.NotFound() + + context = api.request.context + api_utils.check_policy('baremetal:runbook:create') + + cdict = context.to_policy_values() + if cdict.get('system_scope') != 'all': + project_id = None + requested_owner = runbook.get('owner', None) + if cdict.get('project_id', False): + project_id = cdict.get('project_id') + + if requested_owner and requested_owner != project_id: + # Translation: If project scoped, and an owner has been + # requested, and that owner does not match the requester's + # project ID value. + msg = _("Cannot create a runbook as a project scoped admin " + "with an owner other than your own project.") + raise exception.Invalid(msg) + + if project_id and runbook.get('public', False): + msg = _("Cannot create a public runbook as a project scoped " + "admin.") + raise exception.Invalid(msg) + # Finally, note the project ID + runbook['owner'] = project_id + + if not runbook.get('uuid'): + runbook['uuid'] = uuidutils.generate_uuid() + new_runbook = objects.Runbook(context, **runbook) + + notify.emit_start_notification(context, new_runbook, 'create') + with notify.handle_error_notification(context, new_runbook, 'create'): + new_runbook.create() + + # Set the HTTP Location Header + api.response.location = link.build_url('runbooks', new_runbook.uuid) + api_runbook = convert_with_links(new_runbook) + notify.emit_end_notification(context, new_runbook, 'create') + return api_runbook + + def _authorize_patch_and_get_runbook(self, runbook_ident, patch): + # deal with attribute-specific policy rules + policy_checks = [] + generic_update = False + + paths_to_policy = ( + ('/owner', 'baremetal:runbook:update:owner'), + ('/public', 'baremetal:runbook:update:public'), + ) + for p in patch: + # Process general direct path to policy map + rule_match_found = False + for check_path, policy_name in paths_to_policy: + if p['path'].startswith(check_path): + policy_checks.append(policy_name) + # Break, policy found + rule_match_found = True + break + if not rule_match_found: + generic_update = True + + if generic_update or not policy_checks: + # If we couldn't find specific policy to apply, + # apply the update policy check. + policy_checks.append('baremetal:runbook:update') + return api_utils.check_multiple_runbook_policies_and_retrieve( + policy_checks, runbook_ident) + + @METRICS.timer('RunbooksController.patch') + @method.expose() + @method.body('patch') + @args.validate(runbook_ident=args.uuid_or_name, patch=args.patch) + def patch(self, runbook_ident, patch=None): + """Update an existing runbook. + + :param runbook_ident: UUID or logical name of a runbook. + :param patch: a json PATCH document to apply to this runbook. + """ + if not api_utils.allow_runbooks(): + raise exception.NotFound() + + api_utils.patch_validate_allowed_fields(patch, PATCH_ALLOWED_FIELDS) + + context = api.request.context + + rpc_runbook = self._authorize_patch_and_get_runbook(runbook_ident, + patch) + runbook = rpc_runbook.as_dict() + + owner = api_utils.get_patch_values(patch, '/owner') + public = api_utils.get_patch_values(patch, '/public') + + if owner: + # NOTE(cid): There should not be an owner for a public runbook, + # but an owned runbook can be set to non-public and assigned an + # owner atomically + public_value = public[0] if public else False + if runbook.get('public') and (not public) or public_value: + msg = _("There cannot be an owner for a public runbook") + raise exception.PatchError(patch=patch, reason=msg) + + if public: + runbook['owner'] = None + + # apply the patch + runbook = api_utils.apply_jsonpatch(runbook, patch) + + # validate the result with the patch schema + for step in runbook.get('steps', []): + api_utils.patched_validate_with_schema( + step, api_utils.RUNBOOK_STEP_SCHEMA) + api_utils.patched_validate_with_schema( + runbook, RUNBOOK_SCHEMA, RUNBOOK_VALIDATOR) + + api_utils.patch_update_changed_fields( + runbook, rpc_runbook, fields=objects.Runbook.fields, + schema=RUNBOOK_SCHEMA + ) + + notify.emit_start_notification(context, rpc_runbook, 'update') + with notify.handle_error_notification(context, rpc_runbook, 'update'): + rpc_runbook.save() + + api_runbook = convert_with_links(rpc_runbook) + notify.emit_end_notification(context, rpc_runbook, 'update') + + return api_runbook + + @METRICS.timer('RunbooksController.delete') + @method.expose(status_code=http_client.NO_CONTENT) + @args.validate(runbook_ident=args.uuid_or_name) + def delete(self, runbook_ident): + """Delete a runbook. + + :param runbook_ident: UUID or logical name of a runbook. + """ + if not api_utils.allow_runbooks(): + raise exception.NotFound() + + rpc_runbook = api_utils.check_runbook_policy_and_retrieve( + policy_name='baremetal:runbook:delete', + runbook_ident=runbook_ident) + + context = api.request.context + notify.emit_start_notification(context, rpc_runbook, 'delete') + with notify.handle_error_notification(context, rpc_runbook, 'delete'): + rpc_runbook.destroy() + notify.emit_end_notification(context, rpc_runbook, 'delete') diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index fa867960e5..b5b8b94ac1 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -13,6 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. +import collections import copy from http import client as http_client import inspect @@ -158,6 +159,24 @@ DEPLOY_STEP_SCHEMA = { 'additionalProperties': False, } +RUNBOOK_STEP_SCHEMA = { + 'type': 'object', + 'properties': { + 'args': {'type': 'object'}, + 'interface': { + 'type': 'string', + 'enum': list(conductor_steps.CLEANING_INTERFACE_PRIORITY) + }, + 'step': {'type': 'string', 'minLength': 1}, + 'order': {'anyOf': [ + {'type': 'integer', 'minimum': 0}, + {'type': 'string', 'minLength': 1, 'pattern': '^[0-9]+$'} + ]} + }, + 'required': ['interface', 'step', 'order'], + 'additionalProperties': False, +} + def local_link_normalize(name, value): if not value: @@ -685,6 +704,43 @@ def get_rpc_deploy_template_with_suffix(template_ident): exception.DeployTemplateNotFound) +def get_rpc_runbook(runbook_ident): + """Get the RPC runbook from the UUID or logical name. + + :param runbook_ident: the UUID or logical name of a runbook. + + :returns: The RPC runbook. + :raises: InvalidUuidOrName if the name or uuid provided is not valid. + :raises: RunbookNotFound if the runbook is not found. + """ + # If runbook_ident is instead a valid UUID, treat it as a UUID. + if uuidutils.is_uuid_like(runbook_ident): + return objects.Runbook.get_by_uuid(api.request.context, + runbook_ident) + + # Else, we can refer to runbooks by their name too + if utils.is_valid_logical_name(runbook_ident): + return objects.Runbook.get_by_name(api.request.context, + runbook_ident) + raise exception.InvalidUuidOrName(name=runbook_ident) + + +def check_runbook_policy_and_retrieve(policy_name, runbook_ident): + """Check if the specified policy authorizes this request on a node. + + :param: policy_name: Name of the policy to check. + :param: runbook_ident: the UUID or logical name of a runbook. + + :raises: HTTPForbidden if the policy forbids access. + :raises: RunbookNotFound if the runbook is not found. + :return: a runbook object + """ + rpc_runbook = get_rpc_runbook(runbook_ident) + check_owner_policy(object_type='runbook', policy_name=policy_name, + owner=rpc_runbook['owner']) + return rpc_runbook + + def is_valid_node_name(name): """Determine if the provided name is a valid node name. @@ -1517,6 +1573,53 @@ def check_policy_true(policy_name): return policy.check_policy(policy_name, cdict, api.request.context) +def duplicate_steps(name, value): + """Argument validator to check template for duplicate steps""" + # 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 template cannot have multiple " + "deploy steps with the same interface and step. " + "Duplicates: %s") % "; ".join(duplicates) + raise exception.InvalidDeployTemplate(err=err) + return value + + +def convert_steps(rpc_steps): + for step in rpc_steps: + result = { + 'interface': step['interface'], + 'step': step['step'], + 'args': step['args'], + } + + if 'priority' in step: + result['priority'] = step['priority'] + elif 'order' in step: + result['order'] = step['order'] + + yield result + + +def allow_runbooks(): + """Check if accessing runbook endpoints is allowed. + + Version 1.92 of the API exposed runbook endpoints. + """ + return api.request.version.minor >= versions.MINOR_92_RUNBOOKS + + def check_owner_policy(object_type, policy_name, owner, lessee=None, conceal_node=False): """Check if the policy authorizes this request on an object. @@ -1547,6 +1650,19 @@ def check_owner_policy(object_type, policy_name, owner, lessee=None, raise +def check_and_retrieve_public_runbook(runbook_ident): + """If policy authorization check fails, check if runbook is public. + + :param: runbook_ident: the UUID or logical name of a runbook. + :raises: HTTPForbidden if runbook is not public. + :return: RPC runbook identified by runbook_ident + """ + rpc_runbook = get_rpc_runbook(runbook_ident) + if not rpc_runbook.public: + raise exception.HTTPForbidden + return rpc_runbook + + def check_node_policy_and_retrieve(policy_name, node_ident, with_suffix=False): """Check if the specified policy authorizes this request on a node. @@ -1635,6 +1751,27 @@ def check_multiple_node_policies_and_retrieve(policy_names, return rpc_node +def check_multiple_runbook_policies_and_retrieve(policy_names, + runbook_ident): + """Check if the specified policies authorize this request on a runbook. + + :param: policy_names: List of policy names to check. + :param: runbook_ident: the UUID or logical name of a runbook. + + :raises: HTTPForbidden if the policy forbids access. + :raises: RunbookNotFound if the runbook is not found. + :return: RPC runbook identified by runbook_ident + """ + rpc_runbook = None + for policy_name in policy_names: + if rpc_runbook is None: + rpc_runbook = check_runbook_policy_and_retrieve(policy_names[0], + runbook_ident) + else: + check_owner_policy('runbook', policy_name, rpc_runbook['owner']) + return rpc_runbook + + def check_list_policy(object_type, owner=None): """Check if the list policy authorizes this request on an object. diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 90d07ffcab..af51000e1c 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -129,6 +129,7 @@ BASE_VERSION = 1 # v1.89: Add API for attaching/detaching virtual media # v1.90: Accept ovn vtep switch metadata schema to port.local_link_connection # v1.91: Remove special treatment of .json for API objects +# v1.92: Add runbooks API MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -222,6 +223,7 @@ MINOR_88_PORT_NAME = 88 MINOR_89_ATTACH_DETACH_VMEDIA = 89 MINOR_90_OVN_VTEP = 90 MINOR_91_DOT_JSON = 91 +MINOR_92_RUNBOOKS = 92 # When adding another version, update: # - MINOR_MAX_VERSION @@ -229,7 +231,7 @@ MINOR_91_DOT_JSON = 91 # explanation of what changed in the new version # - common/release_mappings.py, RELEASE_MAPPING['master']['api'] -MINOR_MAX_VERSION = MINOR_91_DOT_JSON +MINOR_MAX_VERSION = MINOR_92_RUNBOOKS # 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 8237da606d..20d312baa9 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -716,6 +716,22 @@ class InvalidDeployTemplate(Invalid): _msg_fmt = _("Deploy template invalid: %(err)s.") +class RunbookDuplicateName(Conflict): + _msg_fmt = _("A runbook with name %(name)s already exists.") + + +class RunbookAlreadyExists(Conflict): + _msg_fmt = _("A runbook with UUID %(uuid)s already exists.") + + +class RunbookNotFound(NotFound): + _msg_fmt = _("Runbook %(runbook)s could not be found.") + + +class InvalidRunbook(Invalid): + _msg_fmt = _("Runbook invalid: %(err)s.") + + class InvalidKickstartTemplate(Invalid): _msg_fmt = _("The kickstart template is missing required variables") diff --git a/ironic/common/policy.py b/ironic/common/policy.py index fc209b754b..3d9a6795ef 100644 --- a/ironic/common/policy.py +++ b/ironic/common/policy.py @@ -49,7 +49,7 @@ SYSTEM_ADMIN = 'role:admin and system_scope:all' # Generic policy check string for system users who don't require all the # authorization that system administrators typically have. This persona, or -# check string, typically isn't used by default, but it's existence it useful +# check string, typically isn't used by default, but it's existence is useful # in the event a deployment wants to offload some administrative action from # system administrator to system members. # The rule:service_role match here is to enable an elevated level of API @@ -59,7 +59,7 @@ SYSTEM_MEMBER = '(role:member and system_scope:all) or rule:service_role' # noq # Generic policy check string for read-only access to system-level # resources. This persona is useful for someone who needs access -# for auditing or even support. These uses are also able to view +# for auditing or even support. These users are also able to view # project-specific resources where applicable (e.g., listing all # volumes in the deployment, regardless of the project they belong to). # The rule:service_role match here is to enable an elevated level of API @@ -126,6 +126,24 @@ ALLOCATION_OWNER_MANAGER = ('role:manager and project_id:%(allocation.owner)s') ALLOCATION_OWNER_MEMBER = ('role:member and project_id:%(allocation.owner)s') ALLOCATION_OWNER_READER = ('role:reader and project_id:%(allocation.owner)s') +# Members can create/destroy their runbooks. +RUNBOOK_OWNER_ADMIN = ('role:admin and project_id:%(runbook.owner)s') +RUNBOOK_OWNER_MANAGER = ('role:manager and project_id:%(runbook.owner)s') +RUNBOOK_OWNER_MEMBER = ('role:member and project_id:%(runbook.owner)s') +RUNBOOK_OWNER_READER = ('role:reader and project_id:%(runbook.owner)s') + +RUNBOOK_ADMIN = ( + '(' + SYSTEM_MEMBER + ') or (' + RUNBOOK_OWNER_MANAGER + ') or role:service' # noqa +) + +RUNBOOK_READER = ( + '(' + SYSTEM_READER + ') or (' + RUNBOOK_OWNER_READER + ') or role:service' # noqa +) + +RUNBOOK_CREATOR = ( + '(' + SYSTEM_MEMBER + ') or role:manager or role:service' # noqa +) + # Used for general operations like changing provision state. SYSTEM_OR_OWNER_MEMBER_AND_LESSEE_ADMIN = ( '(' + SYSTEM_MEMBER + ') or (' + SYSTEM_SERVICE + ') or (' + PROJECT_OWNER_MEMBER + ') or (' + PROJECT_LESSEE_ADMIN + ') or (' + PROJECT_LESSEE_MANAGER + ') or (' + PROJECT_SERVICE + ')' # noqa @@ -862,6 +880,24 @@ node_policies = [ ], deprecated_rule=deprecated_node_set_provision_state ), + policy.DocumentedRuleDefault( + name='baremetal:node:set_provision_state:clean_steps', + check_str=SYSTEM_OR_OWNER_MEMBER_AND_LESSEE_ADMIN, + scope_types=['system', 'project'], + description='Allow execution of arbitrary steps on a node', + operations=[ + {'path': '/nodes/{node_ident}/states/provision', 'method': 'PUT'} + ], + ), + policy.DocumentedRuleDefault( + name='baremetal:node:set_provision_state:service_steps', + check_str=SYSTEM_OR_OWNER_MEMBER_AND_LESSEE_ADMIN, + scope_types=['system', 'project'], + description='Allow execution of arbitrary steps on a node', + operations=[ + {'path': '/nodes/{node_ident}/states/provision', 'method': 'PUT'} + ], + ), policy.DocumentedRuleDefault( name='baremetal:node:set_raid_state', check_str=SYSTEM_MEMBER_OR_OWNER_MEMBER, @@ -1880,6 +1916,89 @@ deploy_template_policies = [ ), ] +runbook_policies = [ + policy.DocumentedRuleDefault( + name='baremetal:runbook:get', + check_str=RUNBOOK_READER, + scope_types=['system', 'project'], + description='Retrieve a single runbook record', + operations=[ + {'path': '/runbooks/{runbook_ident}', 'method': 'GET'} + ], + ), + policy.DocumentedRuleDefault( + name='baremetal:runbook:list', + check_str=API_READER, + scope_types=['system', 'project'], + description='Retrieve multiple runbook records, filtered by ' + 'an explicit owner or the client project_id', + operations=[ + {'path': '/runbooks', 'method': 'GET'} + ], + ), + policy.DocumentedRuleDefault( + name='baremetal:runbook:list_all', + check_str=SYSTEM_READER, + scope_types=['system', 'project'], + description='Retrieve all runbook records', + operations=[ + {'path': '/runbooks', 'method': 'GET'} + ], + ), + policy.DocumentedRuleDefault( + name='baremetal:runbook:create', + check_str=RUNBOOK_CREATOR, + scope_types=['system', 'project'], + description='Create Runbook records', + operations=[{'path': '/runbooks', 'method': 'POST'}], + ), + policy.DocumentedRuleDefault( + name='baremetal:runbook:delete', + check_str=RUNBOOK_ADMIN, + scope_types=['system', 'project'], + description='Delete a runbook record', + operations=[ + {'path': '/runbooks/{runbook_ident}', 'method': 'DELETE'} + ], + ), + policy.DocumentedRuleDefault( + name='baremetal:runbook:update', + check_str=RUNBOOK_ADMIN, + scope_types=['system', 'project'], + description='Update a runbook record', + operations=[ + {'path': '/runbooks/{runbook_ident}', 'method': 'PATCH'} + ], + ), + policy.DocumentedRuleDefault( + name='baremetal:runbook:update:public', + check_str=SYSTEM_MEMBER, + scope_types=['system', 'project'], + description='Set and unset a runbook as public', + operations=[ + {'path': '/runbooks/{runbook_ident}/public', 'method': 'PATCH'} + ], + ), + policy.DocumentedRuleDefault( + name='baremetal:runbook:update:owner', + check_str=SYSTEM_MEMBER, + scope_types=['system', 'project'], + description='Set and unset the owner of a runbook', + operations=[ + {'path': '/runbooks/{runbook_ident}/owner', 'method': 'PATCH'} + ], + ), + policy.DocumentedRuleDefault( + name='baremetal:runbook:use', + check_str=RUNBOOK_ADMIN, + scope_types=['system', 'project'], + description='Allowed to use a runbook for node operations', + operations=[ + {'path': '/nodes/{node_ident}/states/provision', 'method': 'PUT'} + ], + ) +] + def list_policies(): policies = itertools.chain( @@ -1896,6 +2015,7 @@ def list_policies(): allocation_policies, event_policies, deploy_template_policies, + runbook_policies, ) return policies diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index b45f09c8d8..9abbc9a546 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -709,7 +709,7 @@ RELEASE_MAPPING = { # make it below. To release, we will preserve a version matching # the release as a separate block of text, like above. 'master': { - 'api': '1.91', + 'api': '1.92', 'rpc': '1.60', 'objects': { 'Allocation': ['1.1'], @@ -728,6 +728,7 @@ RELEASE_MAPPING = { 'VolumeConnector': ['1.0'], 'VolumeTarget': ['1.0'], 'FirmwareComponent': ['1.0'], + 'Runbook': ['1.0'], } }, } diff --git a/ironic/db/api.py b/ironic/db/api.py index 0f21292767..b6f38a95d3 100644 --- a/ironic/db/api.py +++ b/ironic/db/api.py @@ -1347,6 +1347,101 @@ class Connection(object, metaclass=abc.ABCMeta): :returns: A list of deploy templates. """ + @abc.abstractmethod + def create_runbook(self, values): + """Create a runbook. + + :param values: A dict describing the runbook. For example: + + :: + + { + 'uuid': uuidutils.generate_uuid(), + 'name': 'CUSTOM_DT1', + } + :raises: RunbookDuplicateName if a runbook with the same + name exists. + :raises: RunbookAlreadyExists if a runbook with the same + UUID exists. + :returns: A runbook. + """ + + @abc.abstractmethod + def update_runbook(self, runbook_id, values): + """Update a runbook. + + :param runbook_id: ID of the runbook to update. + :param values: A dict describing the runbook. For example: + + :: + + { + 'uuid': uuidutils.generate_uuid(), + 'name': 'CUSTOM_DT1', + } + :raises: RunbookDuplicateName if a runbook with the same + name exists. + :raises: RunbookNotFound if the runbook does not exist. + :returns: A runbook. + """ + + @abc.abstractmethod + def destroy_runbook(self, runbook_id): + """Destroy a runbook. + + :param runbook_id: ID of the runbook to destroy. + :raises: RunbookNotFound if the runbook does not exist. + """ + + @abc.abstractmethod + def get_runbook_by_id(self, runbook_id): + """Retrieve a runbook by ID. + + :param runbook_id: ID of the runbook to retrieve. + :raises: RunbookNotFound if the runbook does not exist. + :returns: A runbook. + """ + + @abc.abstractmethod + def get_runbook_by_uuid(self, runbook_uuid): + """Retrieve a runbook by UUID. + + :param runbook_uuid: UUID of the runbook to retrieve. + :raises: RunbookNotFound if the runbook does not exist. + :returns: A runbook. + """ + + @abc.abstractmethod + def get_runbook_by_name(self, runbook_name): + """Retrieve a runbook by name. + + :param runbook_name: name of the runbook to retrieve. + :raises: RunbookNotFound if the runbook does not exist. + :returns: A runbook. + """ + + @abc.abstractmethod + def get_runbook_list(self, limit=None, marker=None, filters=None, + sort_key=None, sort_dir=None): + """Retrieve a list of runbooks. + + :param limit: Maximum number of runbooks to return. + :param marker: The last item of the previous page; we return the next + result set. + :param sort_key: Attribute by which results should be sorted. + :param sort_dir: Direction in which results should be sorted. + (asc, desc) + :returns: A list of runbooks. + """ + + @abc.abstractmethod + def get_runbook_list_by_names(self, names): + """Return a list of runbooks with one of a list of names. + + :param names: List of names to filter by. + :returns: A list of runbooks. + """ + @abc.abstractmethod def create_node_history(self, values): """Create a new history record. diff --git a/ironic/db/sqlalchemy/alembic/versions/66bd9c5604d5_add_runbook_and_runbook_step.py b/ironic/db/sqlalchemy/alembic/versions/66bd9c5604d5_add_runbook_and_runbook_step.py new file mode 100644 index 0000000000..430578851f --- /dev/null +++ b/ironic/db/sqlalchemy/alembic/versions/66bd9c5604d5_add_runbook_and_runbook_step.py @@ -0,0 +1,70 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Create runbooks and runbook_steps tables + +Revision ID: 66bd9c5604d5 +Revises: 01f21d5e5195 +Create Date: 2024-05-29 19:33:53.268794 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '66bd9c5604d5' +down_revision = '01f21d5e5195' + + +def upgrade(): + op.create_table( + 'runbooks', + sa.Column('version', sa.String(length=15), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False, + autoincrement=True), + sa.Column('uuid', sa.String(length=36)), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('disable_ramdisk', sa.Boolean, default=False), + sa.Column('public', sa.Boolean, default=False), + sa.Column('owner', sa.String(length=255), nullable=True), + sa.Column('extra', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('uuid', name='uniq_runbooks0uuid'), + sa.UniqueConstraint('name', name='uniq_runbooks0name'), + mysql_engine='InnoDB', + mysql_charset='UTF8MB3' + ) + op.create_table( + 'runbook_steps', + sa.Column('version', sa.String(length=15), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False, + autoincrement=True), + sa.Column('runbook_id', sa.Integer(), nullable=False, + autoincrement=False), + sa.Column('interface', sa.String(length=255), nullable=False), + sa.Column('step', sa.String(length=255), nullable=False), + sa.Column('args', sa.Text, nullable=False), + sa.Column('order', sa.Integer, nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['runbook_id'], + ['runbooks.id']), + sa.Index('runbook_id', 'runbook_id'), + sa.Index('runbook_steps_interface_idx', 'interface'), + sa.Index('runbook_steps_step_idx', 'step'), + mysql_engine='InnoDB', + mysql_charset='UTF8MB3' + ) diff --git a/ironic/db/sqlalchemy/api.py b/ironic/db/sqlalchemy/api.py index 873d35c208..ddb48610f5 100644 --- a/ironic/db/sqlalchemy/api.py +++ b/ironic/db/sqlalchemy/api.py @@ -169,6 +169,16 @@ def _get_deploy_template_select_with_steps(): ).options(selectinload(models.DeployTemplate.steps)) +def _get_runbook_select_with_steps(): + """Return a select object for the Runbook joined with steps. + + :returns: a select object. + """ + return sa.select( + models.Runbook + ).options(selectinload(models.Runbook.steps)) + + def model_query(model, *args, **kwargs): """Query helper for simpler session usage. @@ -471,6 +481,13 @@ class Connection(api.Connection): | set(_NODE_IN_QUERY_FIELDS) | set(_NODE_NON_NULL_FILTERS)) + _RUNBOOK_QUERY_FIELDS = {'id', 'uuid', 'name', 'public', 'owner', + 'disable_ramdisk'} + _RUNBOOK_IN_QUERY_FIELDS = {'%s_in' % field: field + for field in ('id', 'uuid', 'name')} + _RUNBOOK_FILTERS = ({'project'} | _RUNBOOK_QUERY_FIELDS + | set(_RUNBOOK_IN_QUERY_FIELDS)) + def __init__(self): pass @@ -541,6 +558,31 @@ class Connection(api.Connection): # a full list of both parents and children being conveyed. return query + def _validate_runbooks_filters(self, filters): + if filters is None: + filters = dict() + unsupported_filters = set(filters).difference(self._RUNBOOK_FILTERS) + if unsupported_filters: + msg = _("SqlAlchemy API does not support " + "filtering by %s") % ', '.join(unsupported_filters) + raise ValueError(msg) + return filters + + def _add_runbooks_filters(self, query, filters): + filters = self._validate_runbooks_filters(filters) + for field in self._RUNBOOK_QUERY_FIELDS: + if field in filters: + query = query.filter_by(**{field: filters[field]}) + for key, field in self._RUNBOOK_IN_QUERY_FIELDS.items(): + if key in filters: + query = query.filter( + getattr(models.Runbook, field).in_(filters[key])) + if 'project' in filters: + project = filters['project'] + query = query.filter((models.Runbook.owner == project) + | (models.Runbook.public)) + return query + def _add_allocations_filters(self, query, filters): if filters is None: filters = dict() @@ -2628,6 +2670,171 @@ class Connection(api.Connection): ).all() return [r[0] for r in res] + @staticmethod + def _get_runbook_steps(steps, runbook_id=None): + results = [] + for values in steps: + step = models.RunbookStep() + step.update(values) + if runbook_id: + step['runbook_id'] = runbook_id + results.append(step) + return results + + @oslo_db_api.retry_on_deadlock + def create_runbook(self, values): + steps = values.get('steps', []) + values['steps'] = self._get_runbook_steps(steps) + + runbook = models.Runbook() + runbook.update(values) + with _session_for_write() as session: + try: + session.add(runbook) + session.flush() + except db_exc.DBDuplicateEntry as e: + if 'name' in e.columns: + raise exception.RunbookDuplicateName( + name=values['name']) + raise exception.RunbookAlreadyExists( + uuid=values['uuid']) + return runbook + + def _update_runbook_steps(self, session, runbook_id, steps): + """Update the steps for a runbook. + + :param session: DB session object. + :param runbook_id: runbook ID. + :param steps: list of steps that should exist for the runbook. + """ + + def _step_key(step): + """Compare two runbook steps.""" + # NOTE(mgoddard): In python 3, dicts are not orderable so cannot be + # used as a sort key. Serialise the step arguments to a JSON string + # for comparison. Taken from https://stackoverflow.com/a/22003440. + sortable_args = json.dumps(step.args, sort_keys=True) + return step.interface, step.step, sortable_args, step.order + + # List all existing steps for the runbook. + current_steps = (session.query(models.RunbookStep) + .filter_by(runbook_id=runbook_id)) + + # List the new steps for the runbook. + new_steps = self._get_runbook_steps(steps, runbook_id) + + # The following is an efficient way to ensure that the steps in the + # database match those that have been requested. We compare the current + # and requested steps in a single pass using the _zip_matching + # function. + steps_to_create = [] + step_ids_to_delete = [] + for current_step, new_step in _zip_matching(current_steps, new_steps, + _step_key): + if current_step is None: + # No matching current step found for this new step - create. + steps_to_create.append(new_step) + elif new_step is None: + # No matching new step found for this current step - delete. + step_ids_to_delete.append(current_step.id) + # else: steps match, no work required. + + # Delete and create steps in bulk as necessary. + if step_ids_to_delete: + ((session.query(models.RunbookStep) + .filter(models.RunbookStep.id.in_(step_ids_to_delete))) + .delete(synchronize_session=False)) + if steps_to_create: + session.bulk_save_objects(steps_to_create) + + @oslo_db_api.retry_on_deadlock + def update_runbook(self, runbook_id, values): + if 'uuid' in values: + msg = _("Cannot overwrite UUID for an existing runbook.") + raise exception.InvalidParameterValue(err=msg) + + try: + with _session_for_write() as session: + # NOTE(mgoddard): Don't issue a joined query for the update as + # this does not work with PostgreSQL. + query = session.query(models.Runbook) + query = add_identity_filter(query, runbook_id) + ref = query.with_for_update().one() + # First, update non-step columns. + steps = values.pop('steps', None) + ref.update(values) + # If necessary, update steps. + if steps is not None: + self._update_runbook_steps(session, ref.id, steps) + session.flush() + + with _session_for_read() as session: + # Return the updated runbook joined with all relevant fields. + query = _get_runbook_select_with_steps() + query = add_identity_filter(query, runbook_id) + res = session.execute(query).one()[0] + return res + except db_exc.DBDuplicateEntry as e: + if 'name' in e.columns: + raise exception.RunbookDuplicateName( + name=values['name']) + raise + except NoResultFound: + # TODO(TheJulia): What would unified core raise?!? + raise exception.RunbookNotFound( + runbook=runbook_id) + + @oslo_db_api.retry_on_deadlock + def destroy_runbook(self, runbook_id): + with _session_for_write() as session: + session.query(models.RunbookStep).filter_by( + runbook_id=runbook_id).delete() + count = session.query(models.Runbook).filter_by( + id=runbook_id).delete() + if count == 0: + raise exception.RunbookNotFound(runbook=runbook_id) + + def _get_runbook(self, field, value): + """Helper method for retrieving a runbook.""" + query = (_get_runbook_select_with_steps() + .where(field == value)) + try: + with _session_for_read() as session: + res = session.execute(query).one()[0] + return res + except NoResultFound: + raise exception.RunbookNotFound(runbook=value) + + def get_runbook_by_id(self, runbook_id): + return self._get_runbook(models.Runbook.id, + runbook_id) + + def get_runbook_by_uuid(self, runbook_uuid): + return self._get_runbook(models.Runbook.uuid, + runbook_uuid) + + def get_runbook_by_name(self, runbook_name): + return self._get_runbook(models.Runbook.name, + runbook_name) + + def get_runbook_list(self, limit=None, marker=None, filters=None, + sort_key=None, sort_dir=None): + query = (sa.select(models.Runbook) + .options(selectinload(models.Runbook.steps))) + query = self._add_runbooks_filters(query, filters) + return _paginate_query(models.Runbook, limit, marker, + sort_key, sort_dir, query) + + def get_runbook_list_by_names(self, names): + query = _get_runbook_select_with_steps() + with _session_for_read() as session: + res = session.execute( + query.where( + models.Runbook.name.in_(names) + ) + ).all() + return [r[0] for r in res] + @oslo_db_api.retry_on_deadlock def create_node_history(self, values): values['uuid'] = uuidutils.generate_uuid() diff --git a/ironic/db/sqlalchemy/models.py b/ironic/db/sqlalchemy/models.py index ff4dcc522b..fbe5c1ff0a 100644 --- a/ironic/db/sqlalchemy/models.py +++ b/ironic/db/sqlalchemy/models.py @@ -516,6 +516,51 @@ class FirmwareComponent(Base): last_version_flashed = Column(String(255), nullable=True) +class Runbook(Base): + """Represents a runbook.""" + + __tablename__ = 'runbooks' + __table_args__ = ( + schema.UniqueConstraint('uuid', name='uniq_runbooks0uuid'), + schema.UniqueConstraint('name', name='uniq_runbooks0name'), + table_args()) + id = Column(Integer, primary_key=True) + uuid = Column(String(36)) + name = Column(String(255), nullable=False) + public = Column(Boolean, default=False) + owner = Column(String(255), nullable=True) + disable_ramdisk = Column(Boolean, default=False) + extra = Column(db_types.JsonEncodedDict) + steps: orm.Mapped[List['RunbookStep']] = orm.relationship( # noqa + "RunbookStep", + back_populates="runbook", + lazy="selectin") + + +class RunbookStep(Base): + """Represents a deployment step in a runbook.""" + + __tablename__ = 'runbook_steps' + __table_args__ = ( + Index('runbook_id', 'runbook_id'), + Index('runbook_steps_interface_idx', 'interface'), + Index('runbook_steps_step_idx', 'step'), + table_args()) + id = Column(Integer, primary_key=True) + runbook_id = Column(Integer, ForeignKey('runbooks.id'), nullable=False) + interface = Column(String(255), nullable=False) + step = Column(String(255), nullable=False) + args = Column(db_types.JsonEncodedDict, nullable=False) + order = Column(Integer, nullable=False) + runbook = orm.relationship( + "Runbook", + primaryjoin=( + 'and_(RunbookStep.runbook_id == ' + 'Runbook.id)'), + foreign_keys=runbook_id + ) + + def get_class(model_name): """Returns the model class with the specified name. diff --git a/ironic/objects/__init__.py b/ironic/objects/__init__.py index 83d1facc50..4d3ac2de84 100644 --- a/ironic/objects/__init__.py +++ b/ironic/objects/__init__.py @@ -36,6 +36,7 @@ def register_all(): __import__('ironic.objects.node_inventory') __import__('ironic.objects.port') __import__('ironic.objects.portgroup') + __import__('ironic.objects.runbook') __import__('ironic.objects.trait') __import__('ironic.objects.volume_connector') __import__('ironic.objects.volume_target') diff --git a/ironic/objects/runbook.py b/ironic/objects/runbook.py new file mode 100644 index 0000000000..66a0656d9a --- /dev/null +++ b/ironic/objects/runbook.py @@ -0,0 +1,252 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_versionedobjects import base as object_base + +from ironic.db import api as db_api +from ironic.objects import base +from ironic.objects import fields as object_fields +from ironic.objects import notification + + +@base.IronicObjectRegistry.register +class Runbook(base.IronicObject, object_base.VersionedObjectDictCompat): + # Version 1.0: Initial version + VERSION = '1.0' + + dbapi = db_api.get_instance() + + fields = { + 'id': object_fields.IntegerField(), + 'uuid': object_fields.UUIDField(nullable=False), + 'name': object_fields.StringField(nullable=False), + 'steps': object_fields.ListOfFlexibleDictsField(nullable=False), + 'disable_ramdisk': object_fields.BooleanField(default=False), + 'extra': object_fields.FlexibleDictField(nullable=True), + 'public': object_fields.BooleanField(default=False), + 'owner': object_fields.StringField(nullable=True), + } + + def create(self, context=None): + """Create a Runbook record in the DB. + + :param context: security context. NOTE: This should only + be used internally by the indirection_api, + but, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Runbook(context). + :raises: RunbookDuplicateName if a runbook with the same + name exists. + :raises: RunbookAlreadyExists if a runbook with the same + UUID exists. + """ + values = self.do_version_changes_for_db() + db_template = self.dbapi.create_runbook(values) + self._from_db_object(self._context, self, db_template) + + def save(self, context=None): + """Save updates to this Runbook. + + Column-wise updates will be made based on the result of + self.what_changed(). + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api, + but, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Runbook(context) + :raises: RunbookDuplicateName if a runbook with the same + name exists. + :raises: RunbookNotFound if the runbook does not exist. + """ + updates = self.do_version_changes_for_db() + db_template = self.dbapi.update_runbook(self.uuid, updates) + self._from_db_object(self._context, self, db_template) + + def destroy(self): + """Delete the Runbook from the DB. + + :param context: security context. NOTE: This should only + be used internally by the indirection_api, + but, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Runbook(context). + :raises: RunbookNotFound if the runbook no longer + appears in the database. + """ + self.dbapi.destroy_runbook(self.id) + self.obj_reset_changes() + + @classmethod + def get_by_id(cls, context, runbook_id): + """Find a runbook based on its integer ID. + + :param context: security context. NOTE: This should only + be used internally by the indirection_api, + but, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Runbook(context). + :param runbook_id: The ID of a runbook. + :raises: RunbookNotFound if the runbook no longer + appears in the database. + :returns: a :class:`Runbook` object. + """ + db_template = cls.dbapi.get_runbook_by_id(runbook_id) + template = cls._from_db_object(context, cls(), db_template) + return template + + @classmethod + def get_by_uuid(cls, context, uuid): + """Find a runbook based on its UUID. + + :param context: security context. NOTE: This should only + be used internally by the indirection_api, + but, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Runbook(context). + :param uuid: The UUID of a runbook. + :raises: RunbookNotFound if the runbook no longer + appears in the database. + :returns: a :class:`Runbook` object. + """ + db_template = cls.dbapi.get_runbook_by_uuid(uuid) + template = cls._from_db_object(context, cls(), db_template) + return template + + @classmethod + def get_by_name(cls, context, name): + """Find a runbook based on its name. + + :param context: security context. NOTE: This should only + be used internally by the indirection_api, + but, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Runbook(context). + :param name: The name of a runbook. + :raises: RunbookNotFound if the runbook no longer + appears in the database. + :returns: a :class:`Runbook` object. + """ + db_template = cls.dbapi.get_runbook_by_name(name) + template = cls._from_db_object(context, cls(), db_template) + return template + + @classmethod + def list(cls, context, limit=None, marker=None, sort_key=None, + sort_dir=None, filters=None): + """Return a list of Runbook objects. + + :param context: security context. NOTE: This should only + be used internally by the indirection_api, + but, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Runbook(context). + :param limit: maximum number of resources to return in a single result. + :param marker: pagination marker for large data sets. + :param sort_key: column to sort results by. + :param sort_dir: direction to sort. "asc" or "desc". + :param filters: Filters to apply. + :returns: a list of :class:`Runbook` objects. + """ + db_templates = cls.dbapi.get_runbook_list(limit=limit, marker=marker, + sort_key=sort_key, + sort_dir=sort_dir, + filters=filters) + return cls._from_db_object_list(context, db_templates) + + @classmethod + def list_by_names(cls, context, names): + """Return a list of Runbook objects matching a set of names. + + :param context: security context. NOTE: This should only + be used internally by the indirection_api, + but, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Runbook(context). + :param names: a list of names to filter by. + :returns: a list of :class:`Runbook` objects. + """ + db_templates = cls.dbapi.get_runbook_list_by_names(names) + return cls._from_db_object_list(context, db_templates) + + def refresh(self, context=None): + """Loads updates for this runbook. + + Loads a runbook with the same uuid from the database and + checks for updated attributes. Updates are applied from + the loaded template column by column, if there are any updates. + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api, + but, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Port(context) + :raises: RunbookNotFound if the runbook no longer + appears in the database. + """ + current = self.get_by_uuid(self._context, uuid=self.uuid) + self.obj_refresh(current) + self.obj_reset_changes() + + +@base.IronicObjectRegistry.register +class RunbookCRUDNotification(notification.NotificationBase): + """Notification emitted on runbook API operations.""" + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'payload': object_fields.ObjectField('RunbookCRUDPayload') + } + + +@base.IronicObjectRegistry.register +class RunbookCRUDPayload(notification.NotificationPayloadBase): + # Version 1.0: Initial version + VERSION = '1.0' + + SCHEMA = { + 'created_at': ('runbook', 'created_at'), + 'disable_ramdisk': ('runbook', 'disable_ramdisk'), + 'extra': ('runbook', 'extra'), + 'name': ('runbook', 'name'), + 'owner': ('runbook', 'owner'), + 'public': ('runbook', 'public'), + 'steps': ('runbook', 'steps'), + 'updated_at': ('runbook', 'updated_at'), + 'uuid': ('runbook', 'uuid') + } + + fields = { + 'created_at': object_fields.DateTimeField(nullable=True), + 'disable_ramdisk': object_fields.BooleanField(default=False), + 'extra': object_fields.FlexibleDictField(nullable=True), + 'name': object_fields.StringField(nullable=False), + 'owner': object_fields.StringField(nullable=True), + 'public': object_fields.BooleanField(default=False), + 'steps': object_fields.ListOfFlexibleDictsField(nullable=False), + 'updated_at': object_fields.DateTimeField(nullable=True), + 'uuid': object_fields.UUIDField() + } + + def __init__(self, runbook, **kwargs): + super(RunbookCRUDPayload, self).__init__(**kwargs) + self.populate_schema(runbook=runbook) diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index 5411aa3459..553843d114 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -7117,6 +7117,113 @@ ORHMKeXMO8fcK0By7CiMKwHSXCoEQgfQhWwpMdSsO8LgHCjh87DQc= """ self.assertEqual('application/json', ret.content_type) self.assertEqual(http_client.BAD_REQUEST, ret.status_code) + @mock.patch.object(api_utils, 'check_runbook_policy_and_retrieve', + autospec=True) + @mock.patch.object(rpcapi.ConductorAPI, 'do_node_service', + autospec=True) + def test_service_with_runbooks(self, mock_dns, mock_policy): + objects.TraitList.create(self.context, self.node.id, ['CUSTOM_1']) + self.node.refresh + + self.node.provision_state = states.SERVICEHOLD + self.node.save() + + runbook = mock.Mock() + runbook.name = 'CUSTOM_1' + runbook.steps = [{"step": "upgrade_firmware", "interface": "deploy", + "args": {}}] + mock_policy.return_value = runbook + ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid, + {'target': states.VERBS['service'], + 'runbook': runbook.name}, + headers={api_base.Version.string: + str(api_v1.max_version())}) + self.assertEqual(http_client.ACCEPTED, ret.status_code) + self.assertEqual(b'', ret.body) + mock_policy.assert_has_calls([mock.call('baremetal:runbook:use', + runbook.name)]), + mock_dns.assert_called_once_with(mock.ANY, mock.ANY, + self.node.uuid, runbook.steps, + mock.ANY, topic='test-topic') + + @mock.patch.object(api_utils, 'check_runbook_policy_and_retrieve', + autospec=True) + @mock.patch.object(rpcapi.ConductorAPI, 'do_node_clean', autospec=True) + @mock.patch.object(api_node, '_check_clean_steps', autospec=True) + def test_clean_with_runbooks(self, mock_check, mock_rpcapi, mock_policy): + objects.TraitList.create(self.context, self.node.id, ['CUSTOM_1']) + self.node.refresh + + self.node.provision_state = states.MANAGEABLE + self.node.save() + + step = {"step": "configure raid", "interface": "raid", "args": {}, + "order": 1} + + runbook = mock.Mock() + runbook.name = 'CUSTOM_1' + runbook.steps = [step] + mock_policy.return_value = runbook + ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid, + {'target': states.VERBS['clean'], + 'runbook': runbook.name}, + headers={api_base.Version.string: + str(api_v1.max_version())}) + self.assertEqual(http_client.ACCEPTED, ret.status_code) + self.assertEqual(b'', ret.body) + mock_policy.assert_has_calls([mock.call('baremetal:runbook:use', + runbook.name)]), + mock_check.assert_called_once_with(runbook.steps) + mock_rpcapi.assert_called_once_with(mock.ANY, mock.ANY, self.node.uuid, + runbook.steps, mock.ANY, + topic='test-topic') + + @mock.patch.object(api_utils, 'check_runbook_policy_and_retrieve', + autospec=True) + def test_service_with_runbooks_unapproved(self, mock_policy): + objects.TraitList.create(self.context, self.node.id, ['CUSTOM_2']) + self.node.refresh + + self.node.provision_state = states.SERVICEHOLD + self.node.save() + + runbook = mock.Mock() + runbook.name = 'CUSTOM_1' + runbook.steps = [{'step': 'meow', 'interface': 'raid', 'args': {}, + 'order': 1}] + mock_policy.return_value = runbook + + ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid, + {'target': states.VERBS['service'], + 'runbook': runbook.name}, + expect_errors=True, + headers={api_base.Version.string: + str(api_v1.max_version())}) + self.assertEqual(http_client.BAD_REQUEST, ret.status_int) + + @mock.patch.object(api_utils, 'check_runbook_policy_and_retrieve', + autospec=True) + def test_clean_with_runbooks_unapproved(self, mock_policy): + objects.TraitList.create(self.context, self.node.id, ['CUSTOM_2']) + self.node.refresh + + self.node.provision_state = states.MANAGEABLE + self.node.save() + + runbook = mock.Mock() + runbook.name = 'CUSTOM_1' + runbook.steps = [{'step': 'meow', 'interface': 'deploy', 'args': {}, + 'order': 1}] + mock_policy.return_value = runbook + + ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid, + {'target': states.VERBS['clean'], + 'runbook': runbook.name}, + expect_errors=True, + headers={api_base.Version.string: + str(api_v1.max_version())}) + self.assertEqual(http_client.BAD_REQUEST, ret.status_int) + class TestCheckCleanSteps(db_base.DbTestCase): def test__check_clean_steps_not_list(self): diff --git a/ironic/tests/unit/api/controllers/v1/test_root.py b/ironic/tests/unit/api/controllers/v1/test_root.py index 903f8d8549..e00fe0a5aa 100644 --- a/ironic/tests/unit/api/controllers/v1/test_root.py +++ b/ironic/tests/unit/api/controllers/v1/test_root.py @@ -160,6 +160,12 @@ class TestV1Routing(api_base.BaseApiTest): 'volume': [ {'href': 'http://localhost/v1/volume/', 'rel': 'self'}, {'href': 'http://localhost/volume/', 'rel': 'bookmark'} + ], + 'runbooks': [ + {'href': 'http://localhost/v1/runbooks/', + 'rel': 'self'}, + {'href': 'http://localhost/runbooks/', + 'rel': 'bookmark'} ] }, response) diff --git a/ironic/tests/unit/api/controllers/v1/test_runbook.py b/ironic/tests/unit/api/controllers/v1/test_runbook.py new file mode 100644 index 0000000000..47af466795 --- /dev/null +++ b/ironic/tests/unit/api/controllers/v1/test_runbook.py @@ -0,0 +1,1126 @@ +# 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 /runbooks/ methods. +""" + +import datetime +from http import client as http_client +from unittest import mock +from urllib import parse as urlparse + +from oslo_config import cfg +from oslo_utils import timeutils +from oslo_utils import uuidutils + +from ironic.api.controllers import base as api_base +from ironic.api.controllers import v1 as api_v1 +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.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 runbook step in 'object' form to one in 'API' form.""" + return { + 'interface': obj_step['interface'], + 'step': obj_step['step'], + 'args': obj_step['args'], + 'order': obj_step['order'], + } + + +class BaseRunbooksAPITest(test_api_base.BaseApiTest): + headers = {api_base.Version.string: str(api_v1.max_version())} + invalid_version_headers = {api_base.Version.string: '1.91'} + + +class TestListRunbooks(BaseRunbooksAPITest): + + def test_empty(self): + data = self.get_json('/runbooks', headers=self.headers) + self.assertEqual([], data['runbooks']) + + def test_one(self): + runbook = obj_utils.create_test_runbook(self.context) + data = self.get_json('/runbooks', headers=self.headers) + self.assertEqual(1, len(data['runbooks'])) + self.assertEqual(runbook.uuid, data['runbooks'][0]['uuid']) + self.assertEqual(runbook.name, data['runbooks'][0]['name']) + self.assertNotIn('steps', data['runbooks'][0]) + self.assertNotIn('extra', data['runbooks'][0]) + + def test_get_one(self): + runbook = obj_utils.create_test_runbook(self.context) + data = self.get_json('/runbooks/%s' % runbook.uuid, + headers=self.headers) + self.assertEqual(runbook.uuid, data['uuid']) + self.assertEqual(runbook.name, data['name']) + self.assertEqual(runbook.extra, data['extra']) + for t_dict_step, t_step in zip(data['steps'], runbook.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['order'], t_step['order']) + + def test_get_one_custom_fields(self): + runbook = obj_utils.create_test_runbook(self.context) + fields = 'name,steps' + data = self.get_json( + '/runbooks/%s?fields=%s' % (runbook.uuid, fields), + headers=self.headers) + # We always append "links" + self.assertCountEqual(['name', 'steps', 'links'], data) + + def test_get_collection_custom_fields(self): + fields = 'uuid,steps' + for i in range(3): + obj_utils.create_test_runbook( + self.context, + uuid=uuidutils.generate_uuid(), + name='CUSTOM_DT%s' % i) + + data = self.get_json( + '/runbooks?fields=%s' % fields, + headers=self.headers) + + self.assertEqual(3, len(data['runbooks'])) + for runbook in data['runbooks']: + # We always append "links" + self.assertCountEqual(['uuid', 'steps', 'links'], runbook) + + def test_get_custom_fields_invalid_fields(self): + runbook = obj_utils.create_test_runbook(self.context) + fields = 'uuid,spongebob' + response = self.get_json( + '/runbooks/%s?fields=%s' % (runbook.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_runbook(self.context) + response = self.get_json('/runbooks', + 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): + runbook = obj_utils.create_test_runbook(self.context) + response = self.get_json( + '/runbooks/%s' % (runbook.uuid), + headers=self.invalid_version_headers, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + + def test_detail_query(self): + runbook = obj_utils.create_test_runbook(self.context) + data = self.get_json('/runbooks?detail=True', + headers=self.headers) + self.assertEqual(runbook.uuid, data['runbooks'][0]['uuid']) + self.assertIn('name', data['runbooks'][0]) + self.assertIn('steps', data['runbooks'][0]) + self.assertIn('extra', data['runbooks'][0]) + + def test_detail_query_false(self): + obj_utils.create_test_runbook(self.context) + data1 = self.get_json('/runbooks', headers=self.headers) + data2 = self.get_json( + '/runbooks?detail=False', headers=self.headers) + self.assertEqual(data1['runbooks'], data2['runbooks']) + + def test_detail_using_query_false_and_fields(self): + obj_utils.create_test_runbook(self.context) + data = self.get_json( + '/runbooks?detail=False&fields=steps', + headers=self.headers) + self.assertIn('steps', data['runbooks'][0]) + self.assertNotIn('uuid', data['runbooks'][0]) + self.assertNotIn('extra', data['runbooks'][0]) + + def test_detail_using_query_and_fields(self): + obj_utils.create_test_runbook(self.context) + response = self.get_json( + '/runbooks?detail=True&fields=name', headers=self.headers, + expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + + def test_many(self): + templates = [] + for id_ in range(5): + runbook = obj_utils.create_test_runbook( + self.context, uuid=uuidutils.generate_uuid(), + name='CUSTOM_DT%s' % id_) + templates.append(runbook.uuid) + data = self.get_json('/runbooks', headers=self.headers) + self.assertEqual(len(templates), len(data['runbooks'])) + + uuids = [n['uuid'] for n in data['runbooks']] + self.assertCountEqual(templates, uuids) + + def test_links(self): + uuid = uuidutils.generate_uuid() + obj_utils.create_test_runbook(self.context, uuid=uuid) + data = self.get_json('/runbooks/%s' % uuid, + headers=self.headers) + self.assertIn('links', data) + self.assertEqual(2, len(data['links'])) + self.assertIn(uuid, data['links'][0]['href']) + for link in data['links']: + bookmark = link['rel'] == 'bookmark' + self.assertTrue(self.validate_link(link['href'], bookmark=bookmark, + headers=self.headers)) + + def test_collection_links(self): + templates = [] + for id_ in range(5): + runbook = obj_utils.create_test_runbook( + self.context, uuid=uuidutils.generate_uuid(), + name='CUSTOM_DT%s' % id_) + templates.append(runbook.uuid) + data = self.get_json('/runbooks/?limit=3', + headers=self.headers) + self.assertEqual(3, len(data['runbooks'])) + + next_marker = data['runbooks'][-1]['uuid'] + self.assertIn('/runbooks', data['next']) + self.assertIn('limit=3', data['next']) + self.assertIn(f'marker={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): + runbook = obj_utils.create_test_runbook( + self.context, uuid=uuidutils.generate_uuid(), + name='CUSTOM_DT%s' % id_) + templates.append(runbook.uuid) + data = self.get_json('/runbooks', headers=self.headers) + self.assertEqual(3, len(data['runbooks'])) + + next_marker = data['runbooks'][-1]['uuid'] + self.assertIn('/runbooks', data['next']) + self.assertIn(f'marker={next_marker}', data['next']) + + def test_collection_links_custom_fields(self): + cfg.CONF.set_override('max_limit', 3, 'api') + templates = [] + fields = 'uuid,steps' + for i in range(5): + runbook = obj_utils.create_test_runbook( + self.context, + uuid=uuidutils.generate_uuid(), + name='CUSTOM_DT%s' % i) + templates.append(runbook.uuid) + data = self.get_json('/runbooks?fields=%s' % fields, + headers=self.headers) + self.assertEqual(3, len(data['runbooks'])) + next_marker = data['runbooks'][-1]['uuid'] + self.assertIn('/runbooks', data['next']) + self.assertIn(f'marker={next_marker}', data['next']) + self.assertIn(f'fields={fields}', data['next']) + + def test_get_collection_pagination_no_uuid(self): + fields = 'name' + limit = 2 + templates = [] + for id_ in range(3): + runbook = obj_utils.create_test_runbook( + self.context, + uuid=uuidutils.generate_uuid(), + name='CUSTOM_DT%s' % id_) + templates.append(runbook) + + data = self.get_json( + '/runbooks?fields=%s&limit=%s' % (fields, limit), + headers=self.headers) + + self.assertEqual(limit, len(data['runbooks'])) + self.assertIn('/runbooks', data['next']) + self.assertIn('marker=%s' % templates[limit - 1].uuid, data['next']) + + def test_sort_key(self): + templates = [] + for id_ in range(3): + runbook = obj_utils.create_test_runbook( + self.context, + uuid=uuidutils.generate_uuid(), + name='CUSTOM_DT%s' % id_) + templates.append(runbook.uuid) + data = self.get_json('/runbooks?sort_key=uuid', + headers=self.headers) + uuids = [n['uuid'] for n in data['runbooks']] + 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 = '/runbooks?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): + runbook = obj_utils.create_test_runbook( + self.context, + uuid=uuidutils.generate_uuid(), + name='CUSTOM_DT%s' % id_) + template_uuids.append(runbook.uuid) + template_uuids.reverse() + url = '/runbooks?sort_key=name&detail=%s' % str(detail) + data = self.get_json(url, headers=self.headers) + data_uuids = [p['uuid'] for p in data['runbooks']] + 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): + runbook = obj_utils.get_test_runbook(self.context) + runbook.steps[0]['args']['password'] = 'correcthorsebatterystaple' + runbook.create() + data = self.get_json('/runbooks/%s' % runbook.uuid, + headers=self.headers) + + self.assertEqual("******", data['steps'][0]['args']['password']) + + +@mock.patch.object(objects.Runbook, 'save', autospec=True) +class TestPatch(BaseRunbooksAPITest): + + def setUp(self): + super(TestPatch, self).setUp() + self.runbook = obj_utils.create_test_runbook( + self.context, name='CUSTOM_DT1') + + def _test_update_ok(self, mock_save, patch): + response = self.patch_json('/runbooks/%s' % self.runbook.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=None): + response = self.patch_json('/runbooks/%s' % self.runbook.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']) + if error_msg: + 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'}, + 'order': 1 + }] + patch = [{'path': '/steps', 'value': steps, 'op': 'replace'}] + response = self.patch_json('/runbooks/%s' % self.runbook.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_name_standard_trait(self, mock_save): + name = 'HW_CPU_X86_VMX' + patch = [{'path': '/name', 'value': name, 'op': 'replace'}] + response = self._test_update_ok(mock_save, patch) + self.assertEqual(name, response.json['name']) + + def test_update_by_id_invalid_api_version(self, mock_save): + name = 'CUSTOM_DT2' + headers = self.invalid_version_headers + response = self.patch_json('/runbooks/%s' % self.runbook.uuid, + [{'path': '/name', + 'value': name, + 'op': 'add'}], + headers=headers, + expect_errors=True) + self.assertEqual(http_client.METHOD_NOT_ALLOWED, response.status_int) + self.assertFalse(mock_save.called) + + def test_update_by_name_old_api_version(self, mock_save): + name = 'CUSTOM_DT2' + response = self.patch_json('/runbooks/%s' % self.runbook.name, + [{'path': '/name', + 'value': name, + 'op': 'add'}], + expect_errors=True) + self.assertEqual(http_client.METHOD_NOT_ALLOWED, 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('/runbooks/%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) + + @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_runbook(self.context, + uuid=uuidutils.generate_uuid(), + name=name) + mock_save.side_effect = exception.RunbookAlreadyExists( + uuid=self.runbook.uuid) + response = self.patch_json('/runbooks/%s' % self.runbook.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, "'%s' is too long" % name) + + def test_replace_invalid_name_none(self, mock_save): + patch = [{'path': '/name', 'op': 'replace', 'value': None}] + self._test_update_bad_request( + mock_save, patch, "None is not of type 'string'") + + def test_replace_duplicate_step(self, mock_save): + # interface & step combination must be unique. + steps = [ + { + 'interface': 'raid', + 'step': 'create_configuration', + 'args': {'foo': '%d' % i}, + 'order': 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'}, + 'order': 1 + } + patch = [{'path': '/steps/0', 'op': 'replace', 'value': step}] + self._test_update_bad_request( + mock_save, patch, "'foo' is not one of") + + def test_replace_non_existent_step_fail(self, mock_save): + step = { + 'interface': 'bios', + 'step': 'apply_configuration', + 'args': {'foo': 'bar'}, + 'order': 1 + } + patch = [{'path': '/steps/1', 'op': 'replace', 'value': step}] + self._test_update_bad_request(mock_save, patch) + + def test_replace_empty_step_list_fail(self, mock_save): + patch = [{'path': '/steps', 'op': 'replace', 'value': []}] + try: + self._test_update_bad_request( + mock_save, patch, "[] is too short") + except Exception: + self._test_update_bad_request( + mock_save, patch, "[] should be non-empty") + + def _test_remove_not_allowed(self, mock_save, field, error_msg=None): + 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', + "Cannot patch /uuid") + + def test_remove_name(self, mock_save): + self._test_remove_not_allowed( + mock_save, 'name', + "'name' is a required property") + + def test_remove_steps(self, mock_save): + self._test_remove_not_allowed( + mock_save, 'steps', + "'steps' is a required property") + + def test_remove_foo(self, mock_save): + self._test_remove_not_allowed(mock_save, '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, "'foo' is not one of") + + def test_replace_multi(self, mock_save): + steps = [ + { + 'interface': 'raid', + 'step': 'create_configuration%d' % i, + 'args': {}, + 'order': 2, + } + for i in range(3) + ] + runbook = obj_utils.create_test_runbook( + self.context, uuid=uuidutils.generate_uuid(), name='CUSTOM_DT2', + steps=steps) + + # mutate steps so we replace all of them + for step in steps: + step['order'] = step['order'] + 1 + + patch = [] + for i, step in enumerate(steps): + patch.append({'path': '/steps/%s' % i, + 'value': step, + 'op': 'replace'}) + response = self.patch_json('/runbooks/%s' % runbook.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': {}, + 'order': 2, + } + for i in range(3) + ] + runbook = obj_utils.create_test_runbook( + self.context, uuid=uuidutils.generate_uuid(), name='CUSTOM_DT2', + steps=steps) + + # Removing one step from the collection + steps.pop(1) + response = self.patch_json('/runbooks/%s' % runbook.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) + + def test_remove_non_existent_step_fail(self, mock_save): + patch = [{'path': '/steps/1', 'op': 'remove'}] + self._test_update_bad_request(mock_save, patch) + + def test_remove_only_step_fail(self, mock_save): + patch = [{'path': '/steps/0', 'op': 'remove'}] + try: + self._test_update_bad_request( + mock_save, patch, "[] is too short") + except Exception: + self._test_update_bad_request( + mock_save, patch, "[] should be non-empty") + + 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) + + def test_add_root_non_existent(self, mock_save): + patch = [{'path': '/foo', 'value': 'bar', 'op': 'add'}] + self._test_update_bad_request( + mock_save, patch, + "Cannot patch /foo") + + def test_add_too_high_index_step_fail(self, mock_save): + step = { + 'interface': 'bios', + 'step': 'apply_configuration', + 'args': {'foo': 'bar'}, + 'order': 1 + } + patch = [{'path': '/steps/2', 'op': 'add', 'value': step}] + self._test_update_bad_request(mock_save, patch) + + def test_add_multi(self, mock_save): + steps = [ + { + 'interface': 'raid', + 'step': 'create_configuration%d' % i, + 'args': {}, + 'order': 2, + } + 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('/runbooks/%s' % self.runbook.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.runbook.steps[0]), + response.json['steps'][-1]) + mock_save.assert_called_once_with(mock.ANY) + + def test_update_project_scope(self, mock_save): + patch = [{'path': '/name', 'value': 'CUSTOM_NAME', 'op': 'replace'}] + response = self.patch_json('/runbooks/%s' % self.runbook.uuid, + patch, + headers={api_base.Version.string: + str(api_v1.max_version()), + 'X-Project-Id': 'projectX'}) + + self.assertEqual(http_client.OK, response.status_code) + + def test_update_system_scope(self, mock_save): + patch = [{'path': '/name', 'value': 'CUSTOM_NAME', 'op': 'replace'}] + response = self.patch_json('/runbooks/%s' % self.runbook.uuid, + patch, + headers={api_base.Version.string: + str(api_v1.max_version()), + 'OpenStack-System-Scope': 'all', + 'X-Roles': 'admin'}) + + self.assertEqual(http_client.OK, response.status_code) + + def test_set_public_system_scope(self, mock_save): + patch = [{'path': '/public', 'value': True, 'op': 'replace'}] + response = self.patch_json('/runbooks/%s' % self.runbook.uuid, + patch, + headers={api_base.Version.string: + str(api_v1.max_version()), + 'OpenStack-System-Scope': 'all', + 'X-Roles': 'admin'}) + + self.assertTrue(response.json['public']) + self.assertIsNone(response.json['owner']) + + def test_set_project_owned_runbook_public(self, mock_save): + tdict = test_api_utils.post_get_test_runbook(name='CUSTOM_UNIQUE1') + runbook = self.post_json('/runbooks', tdict, + headers={api_base.Version.string: + str(api_v1.max_version()), + 'X-Project-Id': 'projectX'}) + self.assertEqual(http_client.CREATED, runbook.status_int) + self.assertEqual('projectX', runbook.json['owner']) + + patch = [{'path': '/public', 'value': True, 'op': 'replace'}] + response = self.patch_json('/runbooks/%s' % runbook.json['uuid'], + patch, + headers={api_base.Version.string: + str(api_v1.max_version()), + 'OpenStack-System-Scope': 'all', + 'X-Roles': 'admin'}) + + self.assertTrue(response.json['public']) + self.assertIsNone(response.json['owner']) + + def test_unset_public_system_scope(self, mock_save): + headers = {api_base.Version.string: str(api_v1.max_version()), + 'OpenStack-System-Scope': 'all', + 'X-Roles': 'admin'} + + tdict = test_api_utils.post_get_test_runbook(name='CUSTOM_UNIQUE2') + tdict['public'] = True + runbook = self.post_json('/runbooks', tdict, headers=headers) + self.assertEqual(http_client.CREATED, runbook.status_int) + + patch = [{'path': '/public', 'value': False, 'op': 'replace'}] + response = self.patch_json('/runbooks/%s' % runbook.json['uuid'], + patch, headers=headers) + self.assertFalse(response.json['public']) + self.assertIsNone(response.json['owner']) + + def test_set_owner_system_scope(self, mock_save): + headers = {api_base.Version.string: str(api_v1.max_version()), + 'OpenStack-System-Scope': 'all', + 'X-Roles': 'admin'} + + tdict = test_api_utils.post_get_test_runbook(name='CUSTOM_UNIQUE1') + runbook = self.post_json('/runbooks', tdict, headers=self.headers) + self.assertEqual(http_client.CREATED, runbook.status_int) + + new_owner = 'projectX' + patch = [{'path': '/owner', 'value': new_owner, 'op': 'replace'}] + response = self.patch_json('/runbooks/%s' % runbook.json['uuid'], + patch, headers=headers) + self.assertEqual(new_owner, response.json['owner']) + + def test_set_new_owner_for_project_owned_runbook(self, mock_save): + tdict = test_api_utils.post_get_test_runbook(name='CUSTOM_UNIQUE1') + runbook = self.post_json('/runbooks', tdict, + headers={api_base.Version.string: + str(api_v1.max_version()), + 'X-Project-Id': 'projectX'}) + self.assertEqual(http_client.CREATED, runbook.status_int) + self.assertEqual('projectX', runbook.json['owner']) + + headers = {api_base.Version.string: str(api_v1.max_version()), + 'OpenStack-System-Scope': 'all', + 'X-Roles': 'admin'} + new_owner = 'projectY' + patch = [{'path': '/owner', 'value': new_owner, 'op': 'replace'}] + response = self.patch_json('/runbooks/%s' % runbook.json['uuid'], + patch, headers=headers) + self.assertEqual(new_owner, response.json['owner']) + + def test_set_owner_system_scope_fails_if_public(self, mock_save): + headers = {api_base.Version.string: str(api_v1.max_version()), + 'OpenStack-System-Scope': 'all', + 'X-Roles': 'admin'} + + tdict = test_api_utils.post_get_test_runbook(name='CUSTOM_UNIQUE1') + tdict['public'] = True + runbook = self.post_json('/runbooks', tdict, headers=headers) + self.assertEqual(http_client.CREATED, runbook.status_int) + + new_owner = 'projectX' + patch = [{'path': '/owner', 'value': new_owner, 'op': 'replace'}] + response = self.patch_json('/runbooks/%s' % runbook.json['uuid'], + patch, headers=headers, expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + + def test_runbook_set_owner_public_system_scope(self, mock_save): + headers = {api_base.Version.string: str(api_v1.max_version()), + 'OpenStack-System-Scope': 'all', + 'X-Roles': 'admin'} + + tdict = test_api_utils.post_get_test_runbook(name='CUSTOM_UNIQUE1') + tdict['public'] = True + runbook = self.post_json('/runbooks', tdict, headers=headers) + self.assertEqual(http_client.CREATED, runbook.status_int) + self.assertTrue(runbook.json['public']) + + new_owner = 'projectX' + patch = [{'path': '/owner', 'value': new_owner, 'op': 'replace'}, + {'path': '/public', 'value': False, 'op': 'replace'}] + response = self.patch_json('/runbooks/%s' % runbook.json['uuid'], + patch, headers=headers) + + self.assertFalse(response.json['public']) + self.assertEqual(new_owner, response.json['owner']) + + def test_runbook_set_owner_public_system_scope_fails(self, mock_save): + headers = {api_base.Version.string: str(api_v1.max_version()), + 'OpenStack-System-Scope': 'all', + 'X-Roles': 'admin'} + + tdict = test_api_utils.post_get_test_runbook(name='CUSTOM_UNIQUE1') + tdict['public'] = True + runbook = self.post_json('/runbooks', tdict, headers=headers) + self.assertEqual(http_client.CREATED, runbook.status_int) + self.assertTrue(runbook.json['public']) + + new_owner = 'projectX' + patch = [{'path': '/owner', 'value': new_owner, 'op': 'replace'}, + {'path': '/public', 'value': True, 'op': 'replace'}] + response = self.patch_json('/runbooks/%s' % runbook.json['uuid'], + patch, headers=headers, expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + + +class TestPost(BaseRunbooksAPITest): + + @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_runbook() + test_time = datetime.datetime(2000, 1, 1, 0, 0) + mock_utcnow.return_value = test_time + response = self.post_json('/runbooks', tdict, + headers=self.headers) + self.assertEqual(http_client.CREATED, response.status_int) + result = self.get_json('/runbooks/%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/runbooks/%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_runbook() + response = self.post_json( + '/runbooks', tdict, headers=self.invalid_version_headers, + expect_errors=True) + self.assertEqual(http_client.METHOD_NOT_ALLOWED, response.status_int) + + def test_create_doesnt_contain_id(self): + with mock.patch.object( + self.dbapi, 'create_runbook', + wraps=self.dbapi.create_runbook) as mock_create: + tdict = test_api_utils.post_get_test_runbook() + self.post_json('/runbooks', tdict, headers=self.headers) + self.get_json('/runbooks/%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_runbook() + del tdict['uuid'] + response = self.post_json('/runbooks', tdict, + headers=self.headers) + result = self.get_json('/runbooks/%s' % response.json['uuid'], + headers=self.headers) + print(mock_warn.call_args) + 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.Runbook, 'create', autospec=True) + def test_create_error(self, mock_create, mock_notify): + mock_create.side_effect = Exception() + tdict = test_api_utils.post_get_test_runbook() + self.post_json('/runbooks', 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('/runbooks', 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('/runbooks', 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_runbook(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_runbook(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_runbook(name=name) + self._test_create_bad_request( + tdict, "'%s' is too long" % name) + + def test_create_steps_invalid_duplicate(self): + steps = [ + { + 'interface': 'raid', + 'step': 'create_configuration', + 'args': {'foo': '%d' % i}, + 'order': i, + } + for i in range(2) + ] + tdict = test_api_utils.post_get_test_runbook(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_runbook() + del tdict[field] + self._test_create_bad_request(tdict, "is a required property") + + 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_runbook() + del tdict['steps'][0][field] + self._test_create_bad_request(tdict, "is a required property") + + 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_order(self): + self._test_create_no_mandatory_step_field('order') + + def _test_create_invalid_field(self, field, value, error_msg): + tdict = test_api_utils.post_get_test_runbook() + tdict[field] = value + self._test_create_bad_request(tdict, error_msg) + + def test_create_invalid_field_name(self): + self._test_create_invalid_field( + 'name', 1, "1 is not of type 'string'") + + def test_create_invalid_field_name_none(self): + self._test_create_invalid_field( + 'name', None, "None is not of type 'string'") + + def test_create_invalid_field_steps(self): + self._test_create_invalid_field( + 'steps', {}, "{} is not of type 'array'") + + def test_create_invalid_field_empty_steps(self): + try: + self._test_create_invalid_field( + 'steps', [], "[] is too short") + except Exception: + self._test_create_invalid_field( + 'steps', [], "[] should be non-empty") + + def test_create_invalid_field_extra(self): + self._test_create_invalid_field( + 'extra', 1, "1 is not of type 'object'") + + def test_create_invalid_field_foo(self): + self._test_create_invalid_field( + 'foo', 'bar', + "Additional properties are not allowed ('foo' was unexpected)") + + def _test_create_invalid_step_field(self, field, value, error_msg=None): + tdict = test_api_utils.post_get_test_runbook() + tdict['steps'][0][field] = value + if error_msg is None: + error_msg = "Deploy runbook invalid: " + self._test_create_bad_request(tdict, error_msg) + + def test_create_invalid_step_field_interface1(self): + self._test_create_invalid_step_field( + 'interface', [3], "[3] is not of type 'string'") + + def test_create_invalid_step_field_interface2(self): + self._test_create_invalid_step_field( + 'interface', 'foo', "'foo' is not one of") + + def test_create_invalid_step_field_step(self): + self._test_create_invalid_step_field( + 'step', 1, "1 is not of type 'string'") + + def test_create_invalid_step_field_args1(self): + self._test_create_invalid_step_field( + 'args', 'not a dict', "'not a dict' is not of type 'object'") + + def test_create_invalid_step_field_args2(self): + self._test_create_invalid_step_field( + 'args', [], "[] is not of type 'object'") + + def test_create_invalid_step_field_order(self): + self._test_create_invalid_step_field( + 'order', 'not a number', + "'not a number'") # differs between jsonschema versions + + def test_create_invalid_step_field_negative_order(self): + self._test_create_invalid_step_field( + 'order', -1, "-1 is less than the minimum of 0") + + def test_create_invalid_step_field_foo(self): + self._test_create_invalid_step_field( + 'foo', 'bar', + "Additional properties are not allowed ('foo' was unexpected)") + + def test_create_step_string_order(self): + tdict = test_api_utils.post_get_test_runbook() + tdict['steps'][0]['order'] = '1' + self._test_create_ok(tdict) + + def test_create_complex_step_args(self): + tdict = test_api_utils.post_get_test_runbook() + tdict['steps'][0]['args'] = {'foo': [{'bar': 'baz'}]} + self._test_create_ok(tdict) + + def test_create_runbook_system_scope(self): + tdict = test_api_utils.post_get_test_runbook() + response = self.post_json('/runbooks', tdict, + headers={api_base.Version.string: + str(api_v1.max_version()), + 'OpenStack-System-Scope': 'all', + 'X-Roles': 'admin'}) + self.assertEqual(http_client.CREATED, response.status_int) + result = self.get_json('/runbooks/%s' % tdict['uuid'], + headers={api_base.Version.string: + str(api_v1.max_version())}) + self.assertIsNone(result['owner']) + self.assertFalse(result['public']) + + def test_create_runbook_owner_system_scope(self): + ndict = test_api_utils.post_get_test_runbook(owner='catsay') + response = self.post_json('/runbooks', ndict, + headers={api_base.Version.string: + str(api_v1.max_version()), + 'OpenStack-System-Scope': 'all', + 'X-Roles': 'admin'}) + self.assertEqual(http_client.CREATED, response.status_int) + result = self.get_json('/runbooks/%s' % ndict['uuid'], + headers={api_base.Version.string: + str(api_v1.max_version())}) + self.assertEqual('catsay', result['owner']) + + def test_create_runbook_project_scope(self): + tdict = test_api_utils.post_get_test_runbook() + response = self.post_json('/runbooks', tdict, + headers={api_base.Version.string: + str(api_v1.max_version()), + 'X-Project-Id': 'projectX'}) + self.assertEqual(http_client.CREATED, response.status_int) + result = self.get_json('/runbooks/%s' % tdict['uuid'], + headers={api_base.Version.string: + str(api_v1.max_version())}) + self.assertEqual(result['owner'], 'projectX') + self.assertFalse(result['public']) + + def test_create_runbook_owner_project_scope_fails(self): + """In project scope, owner has to match the requester's project.""" + ndict = test_api_utils.post_get_test_runbook(owner='catsay') + response = self.post_json('/runbooks', ndict, + headers={api_base.Version.string: + str(api_v1.max_version()), + 'X-Project-Id': 'projectX'}, + expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + + def test_create_public_runbook_project_scope_fails(self): + """A runbook cannot be public in project scope.""" + ndict = test_api_utils.post_get_test_runbook(owner='catsay', + public=True) + response = self.post_json('/runbooks', ndict, + headers={api_base.Version.string: + str(api_v1.max_version()), + 'X-Project-Id': 'projectX'}, + expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + + +@mock.patch.object(objects.Runbook, 'destroy', autospec=True) +class TestDelete(BaseRunbooksAPITest): + + def setUp(self): + super(TestDelete, self).setUp() + self.runbook = obj_utils.create_test_runbook(self.context) + + @mock.patch.object(notification_utils, '_emit_api_notification', + autospec=True) + def test_delete_by_uuid(self, mock_notify, mock_destroy): + self.delete('/runbooks/%s' % self.runbook.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_name(self, mock_destroy): + self.delete('/runbooks/%s' % self.runbook.name, + headers=self.headers) + mock_destroy.assert_called_once_with(mock.ANY) + + def test_delete_invalid_api_version(self, mock_dpt): + response = self.delete('/runbooks/%s' % self.runbook.uuid, + expect_errors=True, + headers=self.invalid_version_headers) + self.assertEqual(http_client.METHOD_NOT_ALLOWED, response.status_int) + + def test_delete_old_api_version(self, mock_dpt): + # Names like CUSTOM_1 were not valid in API 1.1, but the check should + # go after the microversion check. + response = self.delete('/runbooks/%s' % self.runbook.name, + expect_errors=True) + self.assertEqual(http_client.METHOD_NOT_ALLOWED, response.status_int) + + def test_delete_by_name_non_existent(self, mock_dpt): + res = self.delete('/runbooks/%s' % 'blah', expect_errors=True, + headers=self.headers) + self.assertEqual(http_client.NOT_FOUND, res.status_code) diff --git a/ironic/tests/unit/api/test_acl.py b/ironic/tests/unit/api/test_acl.py index 3c85f3d0ea..d4e7e9669f 100644 --- a/ironic/tests/unit/api/test_acl.py +++ b/ironic/tests/unit/api/test_acl.py @@ -298,6 +298,7 @@ class TestRBACModelBeforeScopesBase(TestACLBase): # false positives with test runners. db_utils.create_test_node( uuid='18a552fb-dcd2-43bf-9302-e4c93287be11') + fake_db_runbook = db_utils.create_test_runbook() self.format_data.update({ 'node_ident': fake_db_node['uuid'], 'allocated_node_ident': fake_db_node_alloced['uuid'], @@ -314,6 +315,7 @@ class TestRBACModelBeforeScopesBase(TestACLBase): 'driver_name': 'fake-driverz', 'bios_setting': fake_setting, 'trait': fake_trait, + 'runbook_ident': fake_db_runbook['uuid'], 'volume_target_ident': fake_db_volume_target['uuid'], 'volume_connector_ident': fake_db_volume_connector['uuid'], 'history_ident': fake_history['uuid'], @@ -391,6 +393,9 @@ class TestRBACProjectScoped(TestACLBase): lessee_project_id = 'f11853c7-fa9c-4db3-a477-c9d8e0dbbf13' unowned_node = db_utils.create_test_node(chassis_id=None) + fake_db_runbook = db_utils.create_test_runbook( + owner='70e5e25a-2ca2-4cb1-8ae8-7d8739cee205') + # owned node - since the tests use the same node for # owner/lesse checks owned_node = db_utils.create_test_node( @@ -496,6 +501,7 @@ class TestRBACProjectScoped(TestACLBase): 'vif_ident': fake_vif_port_id, 'ind_component': 'component', 'ind_ident': 'magic_light', + 'runbook_ident': fake_db_runbook['uuid'], 'owner_port_ident': owned_node_port['uuid'], 'other_port_ident': other_port['uuid'], 'owner_portgroup_ident': owner_pgroup['uuid'], diff --git a/ironic/tests/unit/api/test_rbac_project_scoped.yaml b/ironic/tests/unit/api/test_rbac_project_scoped.yaml index a675119677..83185f0936 100644 --- a/ironic/tests/unit/api/test_rbac_project_scoped.yaml +++ b/ironic/tests/unit/api/test_rbac_project_scoped.yaml @@ -3978,3 +3978,314 @@ service_cannot_get_firmware_components: method: get headers: *service_headers assert_status: 404 + +# Runbooks - https://docs.openstack.org/api-ref/baremetal/#runbooks-templates + +runbooks_post_admin: + path: '/v1/runbooks' + method: post + body: &runbook_body + name: 'CUSTOM_NAME' + steps: + - interface: 'raid' + step: 'noop' + args: {} + order: 0 + headers: *owner_admin_headers + assert_status: 201 + +runbooks_post_manager: + path: '/v1/runbooks' + method: post + body: *runbook_body + headers: *owner_manager_headers + assert_status: 201 + +service_post_runbook: + path: '/v1/runbooks' + method: post + body: *runbook_body + headers: *service_headers_owner_project + assert_status: 201 + +third_party_admin_post_runbook: + path: '/v1/runbooks' + method: post + body: *runbook_body + headers: *third_party_admin_headers + assert_status: 201 + +runbooks_post_public_admin: + path: '/v1/runbooks' + method: post + body: &runbook_body_public + name: 'CUSTOM_NAME' + public: true + steps: + - interface: 'raid' + step: 'noop' + args: {} + order: 0 + headers: *owner_admin_headers + assert_status: 400 + +runbooks_post_public_admin: + path: '/v1/runbooks' + method: post + body: *runbook_body_public + headers: *owner_manager_headers + assert_status: 400 + +runbooks_post_public_service: + path: '/v1/runbooks' + method: post + body: *runbook_body_public + headers: *owner_admin_headers + assert_status: 400 + +runbooks_patch_admin: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: &runbook_patch + - op: replace + path: /name + value: 'CUSTOM_NAME' + headers: *owner_admin_headers + assert_status: 200 + +runbooks_patch_manager: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_patch + headers: *owner_manager_headers + assert_status: 200 + +service_patch_runbook: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_patch + headers: *service_headers_owner_project + assert_status: 200 + +project_admin_delete_runbook: + path: '/v1/runbooks/{runbook_ident}' + method: delete + headers: *owner_admin_headers + assert_status: 204 + +project_manager_delete_runbook: + path: '/v1/runbooks/{runbook_ident}' + method: delete + headers: *owner_manager_headers + assert_status: 204 + +service_get_runbooks: + path: '/v1/runbooks' + method: get + headers: *service_headers_owner_project + assert_status: 200 + +service_patch_runbook: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_patch + headers: *service_headers_owner_project + assert_status: 200 + +runbooks_project_admin: + path: '/v1/runbooks' + method: get + headers: *owner_admin_headers + assert_status: 200 + +runbooks_runbook_id_get_project_admin: + path: '/v1/runbooks/{runbook_ident}' + method: get + headers: *owner_admin_headers + assert_status: 200 + +project_admin_patch_runbook: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_patch + headers: *owner_admin_headers + assert_status: 200 + +runbooks_project_manager: + path: '/v1/runbooks' + method: get + headers: *owner_manager_headers + assert_status: 200 + +runbooks_runbook_id_get_project_manager: + path: '/v1/runbooks/{runbook_ident}' + method: get + headers: *owner_manager_headers + assert_status: 200 + +project_manager_patch_runbook: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_patch + headers: *owner_manager_headers + assert_status: 200 + +runbooks_project_member: + path: '/v1/runbooks' + method: get + headers: *owner_member_headers + assert_status: 200 + +runbooks_runbook_id_get_project_member: + path: '/v1/runbooks/{runbook_ident}' + method: get + headers: *owner_member_headers + assert_status: 200 + +runbooks_list_project_reader: + path: '/v1/runbooks' + method: get + headers: *owner_reader_headers + assert_status: 200 + +runbooks_runbook_id_get_project_reader: + path: '/v1/runbooks/{runbook_ident}' + method: get + headers: *owner_reader_headers + assert_status: 200 + +runbooks_list_third_party_admin: + path: '/v1/runbooks' + method: get + headers: *third_party_admin_headers + assert_status: 200 + +project_reader_cannot_post_runbook: + path: '/v1/runbooks' + method: post + body: *runbook_body + headers: *owner_reader_headers + assert_status: 403 + +project_reader_cannot_patch_runbook: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_patch + headers: *owner_reader_headers + assert_status: 403 + +project_reader_cannot_set_runbook_owner: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: &runbook_owner_patch + - op: replace + path: /owner + value: 'new_owner' + headers: *owner_reader_headers + assert_status: 403 + +project_reader_cannot_set_runbook_public: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: &runbook_public_patch + - op: replace + path: /public + value: true + headers: *owner_reader_headers + assert_status: 403 + +project_reader_cannot_delete_runbook: + path: '/v1/runbooks/{runbook_ident}' + method: delete + headers: *owner_reader_headers + assert_status: 403 + +project_member_cannot_post_runbook: + path: '/v1/runbooks' + method: post + body: *runbook_body + headers: *owner_member_headers + assert_status: 403 + +project_member_cannot_patch_runbook: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_patch + headers: *owner_member_headers + assert_status: 403 + +project_member_cannot_set_runbook_owner: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_owner_patch + headers: *owner_member_headers + assert_status: 403 + +project_member_cannot_set_runbook_public: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_public_patch + headers: *owner_member_headers + assert_status: 403 + +project_member_cannot_delete_runbook: + path: '/v1/runbooks/{runbook_ident}' + method: delete + headers: *owner_member_headers + assert_status: 403 + +project_manager_cannot_set_runbook_owner: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_owner_patch + headers: *owner_manager_headers + assert_status: 403 + +project_manager_cannot_set_runbook_public: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_public_patch + headers: *owner_manager_headers + assert_status: 403 + +project_admin_cannot_set_runbook_owner: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_owner_patch + headers: *owner_admin_headers + assert_status: 403 + +project_admin_cannot_set_runbook_public: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_public_patch + headers: *owner_admin_headers + assert_status: 403 + +service_cannot_patch_runbook_owner: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_owner_patch + headers: *service_headers_owner_project + assert_status: 403 + +service_cannot_patch_runbook_public: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_public_patch + headers: *service_headers_owner_project + assert_status: 403 + +third_party_admin_cannot_patch_runbook_owner: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_owner_patch + headers: *third_party_admin_headers + assert_status: 403 + +third_party_admin_cannot_patch_runbook_public: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_public_patch + headers: *third_party_admin_headers + assert_status: 403 diff --git a/ironic/tests/unit/api/test_rbac_system_scoped.yaml b/ironic/tests/unit/api/test_rbac_system_scoped.yaml index 55465602dd..c8224c94e9 100644 --- a/ironic/tests/unit/api/test_rbac_system_scoped.yaml +++ b/ironic/tests/unit/api/test_rbac_system_scoped.yaml @@ -1,5 +1,10 @@ values: skip_reason: "These are fake reference values for YAML templating" + # Project scoped admin token + project_admin_headers: &project_admin_headers + X-Auth-Token: 'owner-admin-token' + X-Roles: admin,manager,member,reader + X-Project-Id: 70e5e25a-2ca2-4cb1-8ae8-7d8739cee205 # System scoped admin token admin_headers: &admin_headers X-Auth-Token: 'baremetal-admin-token' @@ -2584,3 +2589,186 @@ nodes_firmware_component_get_reader: method: get headers: *reader_headers assert_status: 200 + +# Runbooks - https://docs.openstack.org/api-ref/baremetal/#runbooks-templates + +runbooks_post_admin: + path: '/v1/runbooks' + method: post + body: &runbook_body + name: 'CUSTOM_NAME' + steps: + - interface: 'raid' + step: 'noop' + args: {} + order: 0 + headers: *admin_headers + assert_status: 201 + +runbooks_post_member: + path: '/v1/runbooks' + method: post + body: *runbook_body + headers: *scoped_member_headers + assert_status: 201 + +runbooks_post_reader: + path: '/v1/runbooks' + method: post + body: *runbook_body + headers: *reader_headers + assert_status: 403 + +runbooks_get_admin: + path: '/v1/runbooks' + method: get + headers: *admin_headers + assert_status: 200 + +runbooks_get_member: + path: '/v1/runbooks' + method: get + headers: *scoped_member_headers + assert_status: 200 + +runbooks_get_reader: + path: '/v1/runbooks' + method: get + headers: *reader_headers + assert_status: 200 + +runbooks_runbook_id_get_admin: + path: '/v1/runbooks/{runbook_ident}' + method: get + headers: *admin_headers + assert_status: 200 + +runbooks_runbook_id_get_member: + path: '/v1/runbooks/{runbook_ident}' + method: get + headers: *scoped_member_headers + assert_status: 200 + +runbooks_runbook_id_get_reader: + path: '/v1/runbooks/{runbook_ident}' + method: get + headers: *reader_headers + assert_status: 200 + +runbooks_runbook_id_patch_admin: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: &runbook_name_patch + - op: replace + path: /name + value: 'CUSTOM_NAME' + headers: *admin_headers + assert_status: 200 + +runbooks_runbook_id_patch_member: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_name_patch + headers: *scoped_member_headers + assert_status: 200 + +runbooks_runbook_id_patch_reader: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_name_patch + headers: *reader_headers + assert_status: 403 + +runbooks_runbook_id_patch_public_admin: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: &runbook_public_patch + - op: replace + path: /public + value: true + headers: *admin_headers + assert_status: 200 + +runbooks_runbook_id_patch_public_member: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_public_patch + headers: *scoped_member_headers + assert_status: 200 + +runbooks_runbook_id_patch_public_reader: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_public_patch + headers: *reader_headers + assert_status: 403 + +runbooks_runbook_id_patch_owner_admin: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: &runbook_owner_patch + - op: replace + path: /owner + value: 'new_owner' + headers: *admin_headers + assert_status: 200 + +runbooks_runbook_id_patch_owner_member: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_owner_patch + headers: *scoped_member_headers + assert_status: 200 + +runbooks_runbook_id_patch_owner_reader: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_owner_patch + headers: *reader_headers + assert_status: 403 + +runbooks_runbook_id_delete_admin: + path: '/v1/runbooks/{runbook_ident}' + method: delete + headers: *admin_headers + assert_status: 204 + +runbooks_runbook_id_delete_member: + path: '/v1/runbooks/{runbook_ident}' + method: delete + headers: *scoped_member_headers + assert_status: 204 + +runbooks_runbook_id_delete_reader: + path: '/v1/runbooks/{runbook_ident}' + method: delete + headers: *reader_headers + assert_status: 403 + +runbooks_post_project_admin: + path: '/v1/runbooks' + method: post + body: *runbook_body + headers: *project_admin_headers + assert_status: 201 + +runbooks_runbook_id_patch_public_admin: + path: '/v1/runbooks/{runbook_ident}' + method: patch + body: *runbook_public_patch + headers: *admin_headers + assert_status: 200 + +public_runbooks_post_admin: + path: '/v1/runbooks' + method: post + body: &runbook_body_public + name: 'CUSTOM_NAME' + public: true + steps: + - interface: 'raid' + step: 'noop' + args: {} + order: 0 + headers: *admin_headers + assert_status: 201 diff --git a/ironic/tests/unit/api/utils.py b/ironic/tests/unit/api/utils.py index 822325cfc4..f26905d090 100644 --- a/ironic/tests/unit/api/utils.py +++ b/ironic/tests/unit/api/utils.py @@ -27,6 +27,7 @@ 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 runbook as rb_controller 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 @@ -201,6 +202,25 @@ def deploy_template_post_data(**kw): template, dt_controller.TEMPLATE_SCHEMA['properties']) +def runbook_post_data(**kw): + """Return a Runbook object without internal attributes.""" + runbook = db_utils.get_test_runbook(**kw) + # These values are not part of the API object + runbook.pop('version') + # Remove internal attributes from each step. + step_internal = api_utils.RUNBOOK_STEP_SCHEMA['properties'] + runbook['steps'] = [remove_other_fields(step, step_internal) + for step in runbook['steps']] + # Remove internal attributes from the runbook. + return remove_other_fields( + runbook, rb_controller.RUNBOOK_SCHEMA['properties']) + + def post_get_test_deploy_template(**kw): """Return a DeployTemplate object with appropriate attributes.""" return deploy_template_post_data(**kw) + + +def post_get_test_runbook(**kw): + """Return a Runbook object with appropriate attributes.""" + return runbook_post_data(**kw) diff --git a/ironic/tests/unit/common/test_release_mappings.py b/ironic/tests/unit/common/test_release_mappings.py index e6447eda0d..abb41d6567 100644 --- a/ironic/tests/unit/common/test_release_mappings.py +++ b/ironic/tests/unit/common/test_release_mappings.py @@ -101,7 +101,7 @@ class ReleaseMappingsTestCase(base.TestCase): # NodeBase is also excluded as it is covered by Node. exceptions = set(['NodeTag', 'ConductorHardwareInterfaces', 'NodeTrait', 'DeployTemplateStep', - 'NodeBase']) + 'NodeBase', 'RunbookStep']) model_names -= exceptions # NodeTrait maps to two objects model_names |= set(['Trait', 'TraitList']) diff --git a/ironic/tests/unit/db/sqlalchemy/test_migrations.py b/ironic/tests/unit/db/sqlalchemy/test_migrations.py index 416c7a5ba9..7b6b55aa4c 100644 --- a/ironic/tests/unit/db/sqlalchemy/test_migrations.py +++ b/ironic/tests/unit/db/sqlalchemy/test_migrations.py @@ -1169,6 +1169,152 @@ class MigrationCheckersMixin(object): self.assertIsInstance(deploy_templates.c.extra.type, sqlalchemy.types.TEXT) + def _check_dada631878c4(self, engine, data): + # Runbooks. + runbooks = db_utils.get_table(engine, 'runbooks') + col_names = [column.name for column in runbooks.c] + expected = ['created_at', 'updated_at', 'version', + 'id', 'uuid', 'name'] + self.assertEqual(sorted(expected), sorted(col_names)) + self.assertIsInstance(runbooks.c.created_at.type, + sqlalchemy.types.DateTime) + self.assertIsInstance(runbooks.c.updated_at.type, + sqlalchemy.types.DateTime) + self.assertIsInstance(runbooks.c.version.type, + sqlalchemy.types.String) + self.assertIsInstance(runbooks.c.id.type, + sqlalchemy.types.Integer) + self.assertIsInstance(runbooks.c.uuid.type, + sqlalchemy.types.String) + self.assertIsInstance(runbooks.c.name.type, + sqlalchemy.types.String) + + # Runbook steps. + runbook_steps = db_utils.get_table(engine, 'runbook_steps') + col_names = [column.name for column in runbook_steps.c] + expected = ['created_at', 'updated_at', 'version', 'id', + 'runbook_id', 'interface', 'step', 'args', + 'order'] + self.assertEqual(sorted(expected), sorted(col_names)) + + self.assertIsInstance(runbook_steps.c.created_at.type, + sqlalchemy.types.DateTime) + self.assertIsInstance(runbook_steps.c.updated_at.type, + sqlalchemy.types.DateTime) + self.assertIsInstance(runbook_steps.c.version.type, + sqlalchemy.types.String) + self.assertIsInstance(runbook_steps.c.id.type, + sqlalchemy.types.Integer) + self.assertIsInstance(runbook_steps.c.runbook_id.type, + sqlalchemy.types.Integer) + self.assertIsInstance(runbook_steps.c.interface.type, + sqlalchemy.types.String) + self.assertIsInstance(runbook_steps.c.step.type, + sqlalchemy.types.String) + self.assertIsInstance(runbook_steps.c.args.type, + sqlalchemy.types.Text) + self.assertIsInstance(runbook_steps.c.order.type, + sqlalchemy.types.Integer) + + with engine.begin() as connection: + # Insert a Runbook. + uuid = uuidutils.generate_uuid() + name = 'CUSTOM_DT1' + runbook = {'name': name, 'uuid': uuid} + insert_dpt = runbooks.insert().values(runbook) + connection.execute(insert_dpt) + # Query by UUID. + dpt_uuid_stmt = sqlalchemy.select( + models.Runbook.id, + models.Runbook.name, + ).where( + models.Runbook.uuid == uuid + ) + result = connection.execute(dpt_uuid_stmt).first() + runbook_id = result.id + self.assertEqual(name, result.name) + # Query by name. + dpt_name_stmt = sqlalchemy.select( + models.Runbook.id + ).where( + models.Runbook.name == name + ) + result = connection.execute(dpt_name_stmt).first() + self.assertEqual(runbook_id, result.id) + # Query by ID. + dpt_id_stmt = sqlalchemy.select( + models.Runbook.uuid, + models.Runbook.name + ).where( + models.Runbook.id == runbook_id + ) + result = connection.execute(dpt_id_stmt).first() + self.assertEqual(uuid, result.uuid) + self.assertEqual(name, result.name) + savepoint_uuid = connection.begin_nested() + # UUID is unique. + runbook = {'name': 'CUSTOM_DT2', 'uuid': uuid} + self.assertRaises(db_exc.DBDuplicateEntry, connection.execute, + runbooks.insert(), runbook) + savepoint_uuid.rollback() + savepoint_uuid.close() + # Name is unique. + savepoint_name = connection.begin_nested() + runbook = {'name': name, 'uuid': uuidutils.generate_uuid()} + self.assertRaises(db_exc.DBDuplicateEntry, connection.execute, + runbooks.insert(), runbook) + savepoint_name.rollback() + savepoint_name.close() + + # Insert a Runbook step. + interface = 'raid' + step_name = 'create_configuration' + # The line below is JSON. + args = '{"logical_disks": []}' + order = 1 + step = {'runbook_id': runbook_id, 'interface': interface, + 'step': step_name, 'args': args, 'order': order} + insert_dpts = runbook_steps.insert().values(step) + connection.execute(insert_dpts) + # Query by Runbook ID. + query_id_stmt = sqlalchemy.select( + models.RunbookStep.runbook_id, + models.RunbookStep.interface, + models.RunbookStep.step, + models.RunbookStep.args, + models.RunbookStep.order, + ).where( + models.RunbookStep.runbook_id == runbook_id + ) + result = connection.execute(query_id_stmt).first() + self.assertEqual(runbook_id, result.runbook_id) + self.assertEqual(interface, result.interface) + self.assertEqual(step_name, result.step) + if isinstance(result.args, dict): + # Postgres testing results in a dict being returned + # at this level which if you str() it, you get a dict, + # so comparing string to string fails. + result_args = json.dumps(result.args) + else: + # Mysql/MariaDB appears to be actually hand us + # a string back so we should be able to compare it. + result_args = result.args + self.assertEqual(args, result_args) + self.assertEqual(order, result.order) + # Insert another step for the same runbook. + insert_step = runbook_steps.insert().values(step) + connection.execute(insert_step) + + def _check_245c3e54b247(self, engine, data): + # Runbook 'extra' field. + runbooks = db_utils.get_table(engine, 'runbooks') + col_names = [column.name for column in runbooks.c] + expected = ['created_at', 'updated_at', 'version', + 'id', 'uuid', 'name', 'extra'] + self.assertEqual(sorted(expected), sorted(col_names)) + self.assertIsInstance(runbooks.c.extra.type, + sqlalchemy.types.TEXT) + def _check_ce6c4b3cf5a2(self, engine, data): allocations = db_utils.get_table(engine, 'allocations') col_names = [column.name for column in allocations.c] diff --git a/ironic/tests/unit/db/test_runbooks.py b/ironic/tests/unit/db/test_runbooks.py new file mode 100644 index 0000000000..ffabac7f7d --- /dev/null +++ b/ironic/tests/unit/db/test_runbooks.py @@ -0,0 +1,207 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Tests for manipulating Runbooks via the DB API""" + +from oslo_db import exception as db_exc +from oslo_utils import uuidutils + +from ironic.common import exception +from ironic.tests.unit.db import base +from ironic.tests.unit.db import utils as db_utils + + +class DbRunbookTestCase(base.DbTestCase): + + def setUp(self): + super(DbRunbookTestCase, self).setUp() + self.runbook = db_utils.create_test_runbook() + + def test_create(self): + self.assertEqual('CUSTOM_DT1', self.runbook.name) + self.assertEqual(1, len(self.runbook.steps)) + step = self.runbook.steps[0] + self.assertEqual(self.runbook.id, step.runbook_id) + self.assertEqual('raid', step.interface) + self.assertEqual('create_configuration', step.step) + self.assertEqual({'logical_disks': []}, step.args) + self.assertEqual({}, self.runbook.extra) + + def test_create_no_steps(self): + uuid = uuidutils.generate_uuid() + runbook = db_utils.create_test_runbook( + uuid=uuid, name='CUSTOM_DT2', steps=[]) + self.assertEqual([], runbook.steps) + + def test_create_duplicate_uuid(self): + self.assertRaises(exception.RunbookAlreadyExists, + db_utils.create_test_runbook, + uuid=self.runbook.uuid, name='CUSTOM_DT2') + + def test_create_duplicate_name(self): + uuid = uuidutils.generate_uuid() + self.assertRaises(exception.RunbookDuplicateName, + db_utils.create_test_runbook, + uuid=uuid, name=self.runbook.name) + + def test_create_invalid_step_no_interface(self): + uuid = uuidutils.generate_uuid() + runbook = db_utils.get_test_runbook(uuid=uuid, + name='CUSTOM_DT2') + del runbook['steps'][0]['interface'] + self.assertRaises(db_exc.DBError, + self.dbapi.create_runbook, + runbook) + + def test_update_name(self): + values = {'name': 'CUSTOM_DT2'} + runbook = self.dbapi.update_runbook(self.runbook.id, values) + self.assertEqual('CUSTOM_DT2', runbook.name) + + def test_update_steps_replace(self): + step = {'interface': 'bios', 'step': 'apply_configuration', + 'args': {}, 'order': 1} + values = {'steps': [step]} + runbook = self.dbapi.update_runbook(self.runbook.id, values) + self.assertEqual(1, len(runbook.steps)) + step = runbook.steps[0] + self.assertEqual('bios', step.interface) + self.assertEqual('apply_configuration', step.step) + self.assertEqual({}, step.args) + self.assertEqual(1, step.order) + + def test_update_steps_add(self): + step = {'interface': 'bios', 'step': 'apply_configuration', + 'args': {}, 'order': 1} + values = {'steps': [self.runbook.steps[0], step]} + runbook = self.dbapi.update_runbook(self.runbook.id, values) + self.assertEqual(2, len(runbook.steps)) + step0 = runbook.steps[0] + self.assertEqual(self.runbook.steps[0].id, step0.id) + self.assertEqual('raid', step0.interface) + self.assertEqual('create_configuration', step0.step) + self.assertEqual({'logical_disks': []}, step0.args) + step1 = runbook.steps[1] + self.assertNotEqual(self.runbook.steps[0].id, step1.id) + self.assertEqual('bios', step1.interface) + self.assertEqual('apply_configuration', step1.step) + self.assertEqual({}, step1.args) + self.assertEqual(1, step1.order) + + def test_update_steps_replace_args(self): + step = self.runbook.steps[0] + step['args'] = {'foo': 'bar'} + values = {'steps': [step]} + runbook = self.dbapi.update_runbook(self.runbook.id, values) + self.assertEqual(1, len(runbook.steps)) + step = runbook.steps[0] + self.assertEqual({'foo': 'bar'}, step.args) + + def test_update_steps_remove_all(self): + values = {'steps': []} + runbook = self.dbapi.update_runbook(self.runbook.id, values) + self.assertEqual([], runbook.steps) + + def test_update_extra(self): + values = {'extra': {'foo': 'bar'}} + runbook = self.dbapi.update_runbook(self.runbook.id, values) + self.assertEqual({'foo': 'bar'}, runbook.extra) + + def test_update_duplicate_name(self): + uuid = uuidutils.generate_uuid() + runbook2 = db_utils.create_test_runbook(uuid=uuid, + name='CUSTOM_DT2') + values = {'name': self.runbook.name} + self.assertRaises(exception.RunbookDuplicateName, + self.dbapi.update_runbook, runbook2.id, + values) + + def test_update_not_found(self): + self.assertRaises(exception.RunbookNotFound, + self.dbapi.update_runbook, 123, {}) + + def test_update_uuid_not_allowed(self): + uuid = uuidutils.generate_uuid() + self.assertRaises(exception.InvalidParameterValue, + self.dbapi.update_runbook, + self.runbook.id, {'uuid': uuid}) + + def test_destroy(self): + self.dbapi.destroy_runbook(self.runbook.id) + # Attempt to retrieve the runbook to verify it is gone. + self.assertRaises(exception.RunbookNotFound, + self.dbapi.get_runbook_by_id, + self.runbook.id) + # Ensure that the destroy_runbook returns the + # expected exception. + self.assertRaises(exception.RunbookNotFound, + self.dbapi.destroy_runbook, + self.runbook.id) + + def test_get_runbook_by_id(self): + res = self.dbapi.get_runbook_by_id(self.runbook.id) + self.assertEqual(self.runbook.id, res.id) + self.assertEqual(self.runbook.name, res.name) + self.assertEqual(1, len(res.steps)) + self.assertEqual(self.runbook.id, res.steps[0].runbook_id) + self.assertRaises(exception.RunbookNotFound, + self.dbapi.get_runbook_by_id, -1) + + def test_get_runbook_by_uuid(self): + res = self.dbapi.get_runbook_by_uuid(self.runbook.uuid) + self.assertEqual(self.runbook.id, res.id) + invalid_uuid = uuidutils.generate_uuid() + self.assertRaises(exception.RunbookNotFound, + self.dbapi.get_runbook_by_uuid, invalid_uuid) + + def test_get_runbook_by_name(self): + res = self.dbapi.get_runbook_by_name(self.runbook.name) + self.assertEqual(self.runbook.id, res.id) + self.assertRaises(exception.RunbookNotFound, + self.dbapi.get_runbook_by_name, 'bogus') + + def _runbook_list_preparation(self): + uuids = [str(self.runbook.uuid)] + for i in range(1, 3): + runbook = db_utils.create_test_runbook( + uuid=uuidutils.generate_uuid(), + name='CUSTOM_DT%d' % (i + 1)) + uuids.append(str(runbook.uuid)) + return uuids + + def test_get_runbook_list(self): + uuids = self._runbook_list_preparation() + res = self.dbapi.get_runbook_list() + res_uuids = [r.uuid for r in res] + self.assertCountEqual(uuids, res_uuids) + + def test_get_runbook_list_sorted(self): + uuids = self._runbook_list_preparation() + res = self.dbapi.get_runbook_list(sort_key='uuid') + res_uuids = [r.uuid for r in res] + self.assertEqual(sorted(uuids), res_uuids) + + self.assertRaises(exception.InvalidParameterValue, + self.dbapi.get_runbook_list, sort_key='foo') + + def test_get_runbook_list_by_names(self): + self._runbook_list_preparation() + names = ['CUSTOM_DT2', 'CUSTOM_DT3'] + res = self.dbapi.get_runbook_list_by_names(names=names) + res_names = [r.name for r in res] + self.assertCountEqual(names, res_names) + + def test_get_runbook_list_by_names_no_match(self): + self._runbook_list_preparation() + names = ['CUSTOM_FOO'] + res = self.dbapi.get_runbook_list_by_names(names=names) + self.assertEqual([], res) diff --git a/ironic/tests/unit/db/utils.py b/ironic/tests/unit/db/utils.py index 910288a591..d3f13b7956 100644 --- a/ironic/tests/unit/db/utils.py +++ b/ironic/tests/unit/db/utils.py @@ -32,6 +32,7 @@ from ironic.objects import node_history from ironic.objects import node_inventory from ironic.objects import port from ironic.objects import portgroup +from ironic.objects import runbook from ironic.objects import trait from ironic.objects import volume_connector from ironic.objects import volume_target @@ -673,6 +674,59 @@ def create_test_deploy_template(**kw): return dbapi.create_deploy_template(template) +def get_test_runbook(**kw): + default_uuid = uuidutils.generate_uuid() + return { + 'version': kw.get('version', runbook.Runbook.VERSION), + 'created_at': kw.get('created_at'), + 'updated_at': kw.get('updated_at'), + 'id': kw.get('id', 234), + 'name': kw.get('name', u'CUSTOM_DT1'), + 'uuid': kw.get('uuid', default_uuid), + 'steps': kw.get('steps', [get_test_runbook_step( + runbook_id=kw.get('id', 234))]), + 'disable_ramdisk': kw.get('disable_ramdisk', False), + 'extra': kw.get('extra', {}), + 'public': kw.get('public', False), + 'owner': kw.get('owner', None), + } + + +def get_test_runbook_step(**kw): + return { + 'created_at': kw.get('created_at'), + 'updated_at': kw.get('updated_at'), + 'id': kw.get('id', 345), + 'runbook_id': kw.get('runbook_id', 234), + 'interface': kw.get('interface', 'raid'), + 'step': kw.get('step', 'create_configuration'), + 'args': kw.get('args', {'logical_disks': []}), + 'order': kw.get('order', 1) + } + + +def create_test_runbook(**kw): + """Create a runbook in the DB and return Runbook model. + + :param kw: kwargs with overriding values for the runbook. + :returns: Test Runbook DB object. + """ + runbook = get_test_runbook(**kw) + dbapi = db_api.get_instance() + # Let DB generate an ID if one isn't specified explicitly. + if 'id' not in kw: + del runbook['id'] + if 'steps' not in kw: + for step in runbook['steps']: + del step['id'] + del step['runbook_id'] + else: + for kw_step, runbook_step in zip(kw['steps'], runbook['steps']): + if 'id' not in kw_step: + del runbook_step['id'] + return dbapi.create_runbook(runbook) + + def get_test_history(**kw): return { 'id': kw.get('id', 345), diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index 1222e347f4..1e2efce85e 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -724,6 +724,9 @@ expected_object_fingerprints = { 'NodeInventory': '1.0-97692fec24e20ab02022b9db54e8f539', 'FirmwareComponent': '1.0-0e0720dab959e20247bbcfd5f28958c5', 'FirmwareComponentList': '1.0-33a2e1bb91ad4082f9f63429b77c1244', + 'Runbook': '1.0-7a9c65b49b5f7b45686b6a674e703629', + 'RunbookCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', + 'RunbookCRUDPayload': '1.0-f0c97f4ff29eb3401e53b34550a95e30', } diff --git a/ironic/tests/unit/objects/utils.py b/ironic/tests/unit/objects/utils.py index c84f9a51a2..a45f9ad064 100644 --- a/ironic/tests/unit/objects/utils.py +++ b/ironic/tests/unit/objects/utils.py @@ -358,6 +358,41 @@ def get_payloads_with_schemas(from_module): return payloads +def get_test_runbook(ctxt, **kw): + """Return a Runbook object with appropriate attributes. + + NOTE: The object leaves the attributes marked as changed, such + that a create() could be used to commit it to the DB. + """ + db_runbook = db_utils.get_test_runbook(**kw) + # Let DB generate ID if it isn't specified explicitly + if 'id' not in kw: + del db_runbook['id'] + if 'steps' not in kw: + for step in db_runbook['steps']: + del step['id'] + del step['runbook_id'] + else: + for kw_step, runbook_step in zip(kw['steps'], db_runbook['steps']): + if 'id' not in kw_step and 'id' in runbook_step: + del runbook_step['id'] + runbook = objects.Runbook(ctxt) + for key in db_runbook: + setattr(runbook, key, db_runbook[key]) + return runbook + + +def create_test_runbook(ctxt, **kw): + """Create and return a test runbook object. + + NOTE: The object leaves the attributes marked as changed, such + that a create() could be used to commit it to the DB. + """ + runbook = get_test_runbook(ctxt, **kw) + runbook.create() + return runbook + + class SchemasTestMixIn(object): def _check_payload_schemas(self, from_module, fields): """Assert that the Payload SCHEMAs have the expected properties. diff --git a/releasenotes/notes/add-runbooks-38c3efa97ace8c67.yaml b/releasenotes/notes/add-runbooks-38c3efa97ace8c67.yaml new file mode 100644 index 0000000000..a1c451cae3 --- /dev/null +++ b/releasenotes/notes/add-runbooks-38c3efa97ace8c67.yaml @@ -0,0 +1,19 @@ +--- +features: + - | + Adds a new API concept, runbooks, to enable self-service of maintenance + items on nodes by project members. + + Runbooks are curated lists of steps that can be run on nodes only + associated via traits and used in lieu of an explicit list of steps + for manual cleaning or servicing. + - | + Adds a new top-level REST API endpoint `/v1/runbooks/` with basic CRUD + support. + - | + Extends the `/v1/nodes//states/provision` API to accept a runbook + ident (name or UUID) instead of `clean_steps` or `service_steps` for + servicing or manual cleaning. + - | + Implements RBAC-aware lifecycle management for runbooks, allowing projects + to limit who can CRUD and use a runbook.