Merge "Self-Service via Runbooks"

This commit is contained in:
Zuul 2024-08-07 18:03:36 +00:00 committed by Gerrit Code Review
commit 8b296e242b
33 changed files with 3947 additions and 43 deletions

View File

@ -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 <https://tools.ietf.org/html/rfc6902>`_.
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

View File

@ -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": "<runbook_name_or_uuid>"
}
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 <node> \
--clean-steps -
To use a runbook instead of specifying clean steps:
baremetal node clean <node> --runbook <runbook_name_or_uuid>
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 <node> --runbook <runbook_name_or_uuid>
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
================

View File

@ -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": "<runbook_name_or_uuid>"
}
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 <node> \
--service-steps -
To use a runbook instead of specifying service steps:
baremetal node service <node> --runbook <runbook_name_or_uuid>
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 <node> --runbook <runbook_name_or_uuid>
Ensure that the runbook matches one of the node's traits before using it
for servicing.
Available Steps in Ironic
-------------------------

View File

@ -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()

View File

@ -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)

View File

@ -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()

View File

@ -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),

View File

@ -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')

View File

@ -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.

View File

@ -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)

View File

@ -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")

View File

@ -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

View File

@ -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'],
}
},
}