Merge "Self-Service via Runbooks"
This commit is contained in:
commit
8b296e242b
245
api-ref/source/baremetal-api-v1-runbooks.inc
Normal file
245
api-ref/source/baremetal-api-v1-runbooks.inc
Normal 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
|
@ -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
|
RAID without non-root volumes, and then all devices would be erased
|
||||||
(in that order).
|
(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
|
Starting manual cleaning via "openstack metal" CLI
|
||||||
------------------------------------------------------
|
------------------------------------------------------
|
||||||
|
|
||||||
@ -246,6 +255,24 @@ Or with stdin::
|
|||||||
cat my-clean-steps.txt | baremetal node clean <node> \
|
cat my-clean-steps.txt | baremetal node clean <node> \
|
||||||
--clean-steps -
|
--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
|
Cleaning Network
|
||||||
================
|
================
|
||||||
|
|
||||||
|
@ -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
|
called to send a raw command to the BMC. Please note, ``send_raw`` is only
|
||||||
available for the ``ipmi`` hardware type.
|
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
|
Starting servicing via "openstack baremetal" CLI
|
||||||
------------------------------------------------
|
------------------------------------------------
|
||||||
|
|
||||||
@ -137,6 +146,23 @@ Or with stdin::
|
|||||||
cat my-clean-steps.txt | baremetal node service <node> \
|
cat my-clean-steps.txt | baremetal node service <node> \
|
||||||
--service-steps -
|
--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
|
Available Steps in Ironic
|
||||||
-------------------------
|
-------------------------
|
||||||
|
|
||||||
|
@ -36,6 +36,7 @@ from ironic.api.controllers.v1 import node
|
|||||||
from ironic.api.controllers.v1 import port
|
from ironic.api.controllers.v1 import port
|
||||||
from ironic.api.controllers.v1 import portgroup
|
from ironic.api.controllers.v1 import portgroup
|
||||||
from ironic.api.controllers.v1 import ramdisk
|
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 shard
|
||||||
from ironic.api.controllers.v1 import utils
|
from ironic.api.controllers.v1 import utils
|
||||||
from ironic.api.controllers.v1 import versions
|
from ironic.api.controllers.v1 import versions
|
||||||
@ -77,6 +78,7 @@ VERSIONED_CONTROLLERS = {
|
|||||||
'events': utils.allow_expose_events,
|
'events': utils.allow_expose_events,
|
||||||
'deploy_templates': utils.allow_deploy_templates,
|
'deploy_templates': utils.allow_deploy_templates,
|
||||||
'shards': utils.allow_shards_endpoint,
|
'shards': utils.allow_shards_endpoint,
|
||||||
|
'runbooks': utils.allow_runbooks,
|
||||||
# NOTE(dtantsur): continue_inspection is available in 1.1 as a
|
# NOTE(dtantsur): continue_inspection is available in 1.1 as a
|
||||||
# compatibility hack to make it usable with IPA without changes.
|
# compatibility hack to make it usable with IPA without changes.
|
||||||
# Hide this fact from consumers since it was not actually available
|
# Hide this fact from consumers since it was not actually available
|
||||||
@ -131,6 +133,7 @@ class Controller(object):
|
|||||||
'deploy_templates': deploy_template.DeployTemplatesController(),
|
'deploy_templates': deploy_template.DeployTemplatesController(),
|
||||||
'shards': shard.ShardController(),
|
'shards': shard.ShardController(),
|
||||||
'continue_inspection': ramdisk.ContinueInspectionController(),
|
'continue_inspection': ramdisk.ContinueInspectionController(),
|
||||||
|
'runbooks': runbook.RunbooksController()
|
||||||
}
|
}
|
||||||
|
|
||||||
@method.expose()
|
@method.expose()
|
||||||
|
@ -10,7 +10,6 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
import collections
|
|
||||||
from http import client as http_client
|
from http import client as http_client
|
||||||
|
|
||||||
from ironic_lib import metrics_utils
|
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']
|
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(
|
TEMPLATE_VALIDATOR = args.and_valid(
|
||||||
args.schema(TEMPLATE_SCHEMA),
|
args.schema(TEMPLATE_SCHEMA),
|
||||||
duplicate_steps,
|
api_utils.duplicate_steps,
|
||||||
args.dict_valid(uuid=args.uuid)
|
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):
|
def convert_with_links(rpc_template, fields=None, sanitize=True):
|
||||||
"""Add links to the deploy template."""
|
"""Add links to the deploy template."""
|
||||||
template = api_utils.object_to_dict(
|
template = api_utils.object_to_dict(
|
||||||
@ -104,7 +70,7 @@ def convert_with_links(rpc_template, fields=None, sanitize=True):
|
|||||||
fields=('name', 'extra'),
|
fields=('name', 'extra'),
|
||||||
link_resource='deploy_templates',
|
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:
|
if fields is not None:
|
||||||
api_utils.check_for_invalid_fields(fields, template)
|
api_utils.check_for_invalid_fields(fields, template)
|
||||||
|
@ -86,6 +86,10 @@ _STEPS_SCHEMA = {
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {}
|
"properties": {}
|
||||||
},
|
},
|
||||||
|
'order': {'anyOf': [
|
||||||
|
{'type': 'integer', 'minimum': 0},
|
||||||
|
{'type': 'string', 'minLength': 1, 'pattern': '^[0-9]+$'}
|
||||||
|
]},
|
||||||
"execute_on_child_nodes": {
|
"execute_on_child_nodes": {
|
||||||
"description": "Boolean if the step should be executed "
|
"description": "Boolean if the step should be executed "
|
||||||
"on child nodes.",
|
"on child nodes.",
|
||||||
@ -988,6 +992,41 @@ class NodeStatesController(rest.RestController):
|
|||||||
url_args = '/'.join([node_ident, 'states'])
|
url_args = '/'.join([node_ident, 'states'])
|
||||||
api.response.location = link.build_url('nodes', url_args)
|
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,
|
def _do_provision_action(self, rpc_node, target, configdrive=None,
|
||||||
clean_steps=None, deploy_steps=None,
|
clean_steps=None, deploy_steps=None,
|
||||||
rescue_password=None, disable_ramdisk=None,
|
rescue_password=None, disable_ramdisk=None,
|
||||||
@ -1061,11 +1100,12 @@ class NodeStatesController(rest.RestController):
|
|||||||
deploy_steps=args.types(type(None), list),
|
deploy_steps=args.types(type(None), list),
|
||||||
rescue_password=args.string,
|
rescue_password=args.string,
|
||||||
disable_ramdisk=args.boolean,
|
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,
|
def provision(self, node_ident, target, configdrive=None,
|
||||||
clean_steps=None, deploy_steps=None,
|
clean_steps=None, deploy_steps=None,
|
||||||
rescue_password=None, disable_ramdisk=None,
|
rescue_password=None, disable_ramdisk=None,
|
||||||
service_steps=None):
|
service_steps=None, runbook=None):
|
||||||
"""Asynchronous trigger the provisioning of the node.
|
"""Asynchronous trigger the provisioning of the node.
|
||||||
|
|
||||||
This will set the target provision state of the node, and a
|
This will set the target provision state of the node, and a
|
||||||
@ -1142,6 +1182,7 @@ class NodeStatesController(rest.RestController):
|
|||||||
'args': {'force': True},
|
'args': {'force': True},
|
||||||
'priority': 90 }
|
'priority': 90 }
|
||||||
|
|
||||||
|
:param runbook: UUID or logical name of a runbook.
|
||||||
:raises: NodeLocked (HTTP 409) if the node is currently locked.
|
:raises: NodeLocked (HTTP 409) if the node is currently locked.
|
||||||
:raises: ClientSideError (HTTP 409) if the node is already being
|
:raises: ClientSideError (HTTP 409) if the node is already being
|
||||||
provisioned.
|
provisioned.
|
||||||
@ -1187,9 +1228,26 @@ class NodeStatesController(rest.RestController):
|
|||||||
api_utils.check_allow_configdrive(target, configdrive)
|
api_utils.check_allow_configdrive(target, configdrive)
|
||||||
api_utils.check_allow_clean_disable_ramdisk(target, disable_ramdisk)
|
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']:
|
if clean_steps and target != ir_states.VERBS['clean']:
|
||||||
msg = (_('"clean_steps" is only valid when setting target '
|
msg = (_('"clean_steps" is only valid when setting target '
|
||||||
'provision state to %s') % ir_states.VERBS['clean'])
|
'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(
|
raise exception.ClientSideError(
|
||||||
msg, status_code=http_client.BAD_REQUEST)
|
msg, status_code=http_client.BAD_REQUEST)
|
||||||
|
|
||||||
@ -1214,6 +1272,17 @@ class NodeStatesController(rest.RestController):
|
|||||||
if not api_utils.allow_unhold_verb():
|
if not api_utils.allow_unhold_verb():
|
||||||
raise exception.NotAcceptable()
|
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 target == ir_states.VERBS['service']:
|
||||||
if not api_utils.allow_service_verb():
|
if not api_utils.allow_service_verb():
|
||||||
raise exception.NotAcceptable()
|
raise exception.NotAcceptable()
|
||||||
|
@ -28,6 +28,7 @@ from ironic.objects import node as node_objects
|
|||||||
from ironic.objects import notification
|
from ironic.objects import notification
|
||||||
from ironic.objects import port as port_objects
|
from ironic.objects import port as port_objects
|
||||||
from ironic.objects import portgroup as portgroup_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_connector as volume_connector_objects
|
||||||
from ironic.objects import volume_target as volume_target_objects
|
from ironic.objects import volume_target as volume_target_objects
|
||||||
|
|
||||||
@ -48,6 +49,8 @@ CRUD_NOTIFY_OBJ = {
|
|||||||
port_objects.PortCRUDPayload),
|
port_objects.PortCRUDPayload),
|
||||||
'portgroup': (portgroup_objects.PortgroupCRUDNotification,
|
'portgroup': (portgroup_objects.PortgroupCRUDNotification,
|
||||||
portgroup_objects.PortgroupCRUDPayload),
|
portgroup_objects.PortgroupCRUDPayload),
|
||||||
|
'runbook': (runbook_objects.RunbookCRUDNotification,
|
||||||
|
runbook_objects.RunbookCRUDPayload),
|
||||||
'volumeconnector':
|
'volumeconnector':
|
||||||
(volume_connector_objects.VolumeConnectorCRUDNotification,
|
(volume_connector_objects.VolumeConnectorCRUDNotification,
|
||||||
volume_connector_objects.VolumeConnectorCRUDPayload),
|
volume_connector_objects.VolumeConnectorCRUDPayload),
|
||||||
|
391
ironic/api/controllers/v1/runbook.py
Normal file
391
ironic/api/controllers/v1/runbook.py
Normal 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')
|
@ -13,6 +13,7 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
import collections
|
||||||
import copy
|
import copy
|
||||||
from http import client as http_client
|
from http import client as http_client
|
||||||
import inspect
|
import inspect
|
||||||
@ -158,6 +159,24 @@ DEPLOY_STEP_SCHEMA = {
|
|||||||
'additionalProperties': False,
|
'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):
|
def local_link_normalize(name, value):
|
||||||
if not value:
|
if not value:
|
||||||
@ -685,6 +704,43 @@ def get_rpc_deploy_template_with_suffix(template_ident):
|
|||||||
exception.DeployTemplateNotFound)
|
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):
|
def is_valid_node_name(name):
|
||||||
"""Determine if the provided name is a valid node 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)
|
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,
|
def check_owner_policy(object_type, policy_name, owner, lessee=None,
|
||||||
conceal_node=False):
|
conceal_node=False):
|
||||||
"""Check if the policy authorizes this request on an object.
|
"""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
|
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,
|
def check_node_policy_and_retrieve(policy_name, node_ident,
|
||||||
with_suffix=False):
|
with_suffix=False):
|
||||||
"""Check if the specified policy authorizes this request on a node.
|
"""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
|
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):
|
def check_list_policy(object_type, owner=None):
|
||||||
"""Check if the list policy authorizes this request on an object.
|
"""Check if the list policy authorizes this request on an object.
|
||||||
|
|
||||||
|
@ -129,6 +129,7 @@ BASE_VERSION = 1
|
|||||||
# v1.89: Add API for attaching/detaching virtual media
|
# v1.89: Add API for attaching/detaching virtual media
|
||||||
# v1.90: Accept ovn vtep switch metadata schema to port.local_link_connection
|
# v1.90: Accept ovn vtep switch metadata schema to port.local_link_connection
|
||||||
# v1.91: Remove special treatment of .json for API objects
|
# v1.91: Remove special treatment of .json for API objects
|
||||||
|
# v1.92: Add runbooks API
|
||||||
|
|
||||||
MINOR_0_JUNO = 0
|
MINOR_0_JUNO = 0
|
||||||
MINOR_1_INITIAL_VERSION = 1
|
MINOR_1_INITIAL_VERSION = 1
|
||||||
@ -222,6 +223,7 @@ MINOR_88_PORT_NAME = 88
|
|||||||
MINOR_89_ATTACH_DETACH_VMEDIA = 89
|
MINOR_89_ATTACH_DETACH_VMEDIA = 89
|
||||||
MINOR_90_OVN_VTEP = 90
|
MINOR_90_OVN_VTEP = 90
|
||||||
MINOR_91_DOT_JSON = 91
|
MINOR_91_DOT_JSON = 91
|
||||||
|
MINOR_92_RUNBOOKS = 92
|
||||||
|
|
||||||
# When adding another version, update:
|
# When adding another version, update:
|
||||||
# - MINOR_MAX_VERSION
|
# - MINOR_MAX_VERSION
|
||||||
@ -229,7 +231,7 @@ MINOR_91_DOT_JSON = 91
|
|||||||
# explanation of what changed in the new version
|
# explanation of what changed in the new version
|
||||||
# - common/release_mappings.py, RELEASE_MAPPING['master']['api']
|
# - 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
|
# String representations of the minor and maximum versions
|
||||||
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)
|
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)
|
||||||
|
@ -716,6 +716,22 @@ class InvalidDeployTemplate(Invalid):
|
|||||||
_msg_fmt = _("Deploy template invalid: %(err)s.")
|
_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):
|
class InvalidKickstartTemplate(Invalid):
|
||||||
_msg_fmt = _("The kickstart template is missing required variables")
|
_msg_fmt = _("The kickstart template is missing required variables")
|
||||||
|
|
||||||
|
@ -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
|
# Generic policy check string for system users who don't require all the
|
||||||
# authorization that system administrators typically have. This persona, or
|
# 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
|
# in the event a deployment wants to offload some administrative action from
|
||||||
# system administrator to system members.
|
# system administrator to system members.
|
||||||
# The rule:service_role match here is to enable an elevated level of API
|
# 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
|
# Generic policy check string for read-only access to system-level
|
||||||
# resources. This persona is useful for someone who needs access
|
# 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
|
# project-specific resources where applicable (e.g., listing all
|
||||||
# volumes in the deployment, regardless of the project they belong to).
|
# 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
|
# 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_MEMBER = ('role:member and project_id:%(allocation.owner)s')
|
||||||
ALLOCATION_OWNER_READER = ('role:reader 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.
|
# Used for general operations like changing provision state.
|
||||||
SYSTEM_OR_OWNER_MEMBER_AND_LESSEE_ADMIN = (
|
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
|
'(' + 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
|
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(
|
policy.DocumentedRuleDefault(
|
||||||
name='baremetal:node:set_raid_state',
|
name='baremetal:node:set_raid_state',
|
||||||
check_str=SYSTEM_MEMBER_OR_OWNER_MEMBER,
|
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():
|
def list_policies():
|
||||||
policies = itertools.chain(
|
policies = itertools.chain(
|
||||||
@ -1896,6 +2015,7 @@ def list_policies():
|
|||||||
allocation_policies,
|
allocation_policies,
|
||||||
event_policies,
|
event_policies,
|
||||||
deploy_template_policies,
|
deploy_template_policies,
|
||||||
|
runbook_policies,
|
||||||
)
|
)
|
||||||
return policies
|
return policies
|
||||||
|
|
||||||
|
@ -709,7 +709,7 @@ RELEASE_MAPPING = {
|
|||||||
# make it below. To release, we will preserve a version matching
|
# make it below. To release, we will preserve a version matching
|
||||||
# the release as a separate block of text, like above.
|
# the release as a separate block of text, like above.
|
||||||
'master': {
|
'master': {
|
||||||
'api': '1.91',
|
'api': '1.92',
|
||||||
'rpc': '1.60',
|
'rpc': '1.60',
|
||||||
'objects': {
|
'objects': {
|
||||||
'Allocation': ['1.1'],
|
'Allocation': ['1.1'],
|
||||||
@ -728,6 +728,7 @@ RELEASE_MAPPING = {
|
|||||||
'VolumeConnector': ['1.0'],
|
'VolumeConnector': ['1.0'],
|
||||||
'VolumeTarget': ['1.0'],
|
'VolumeTarget': ['1.0'],
|
||||||
'FirmwareComponent': ['1.0'],
|
'FirmwareComponent': ['1.0'],
|
||||||
|
'Runbook': ['1.0'],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -1347,6 +1347,101 @@ class Connection(object, metaclass=abc.ABCMeta):
|
|||||||
:returns: A list of deploy templates.
|
: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
|
@abc.abstractmethod
|
||||||
def create_node_history(self, values):
|
def create_node_history(self, values):
|
||||||
"""Create a new history record.
|
"""Create a new history record.
|
||||||
|
@ -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'
|
||||||
|
)
|
@ -169,6 +169,16 @@ def _get_deploy_template_select_with_steps():
|
|||||||
).options(selectinload(models.DeployTemplate.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):
|
def model_query(model, *args, **kwargs):
|
||||||
"""Query helper for simpler session usage.
|
"""Query helper for simpler session usage.
|
||||||
|
|
||||||
@ -471,6 +481,13 @@ class Connection(api.Connection):
|
|||||||
| set(_NODE_IN_QUERY_FIELDS)
|
| set(_NODE_IN_QUERY_FIELDS)
|
||||||
| set(_NODE_NON_NULL_FILTERS))
|
| 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):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -541,6 +558,31 @@ class Connection(api.Connection):
|
|||||||
# a full list of both parents and children being conveyed.
|
# a full list of both parents and children being conveyed.
|
||||||
return query
|
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):
|
def _add_allocations_filters(self, query, filters):
|
||||||
if filters is None:
|
if filters is None:
|
||||||
filters = dict()
|
filters = dict()
|
||||||
@ -2628,6 +2670,171 @@ class Connection(api.Connection):
|
|||||||
).all()
|
).all()
|
||||||
return [r[0] for r in res]
|
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
|
@oslo_db_api.retry_on_deadlock
|
||||||
def create_node_history(self, values):
|
def create_node_history(self, values):
|
||||||
values['uuid'] = uuidutils.generate_uuid()
|
values['uuid'] = uuidutils.generate_uuid()
|
||||||
|
@ -516,6 +516,51 @@ class FirmwareComponent(Base):
|
|||||||
last_version_flashed = Column(String(255), nullable=True)
|
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):
|
def get_class(model_name):
|
||||||
"""Returns the model class with the specified name.
|
"""Returns the model class with the specified name.
|
||||||
|
|
||||||
|
@ -36,6 +36,7 @@ def register_all():
|
|||||||
__import__('ironic.objects.node_inventory')
|
__import__('ironic.objects.node_inventory')
|
||||||
__import__('ironic.objects.port')
|
__import__('ironic.objects.port')
|
||||||
__import__('ironic.objects.portgroup')
|
__import__('ironic.objects.portgroup')
|
||||||
|
__import__('ironic.objects.runbook')
|
||||||
__import__('ironic.objects.trait')
|
__import__('ironic.objects.trait')
|
||||||
__import__('ironic.objects.volume_connector')
|
__import__('ironic.objects.volume_connector')
|
||||||
__import__('ironic.objects.volume_target')
|
__import__('ironic.objects.volume_target')
|
||||||
|
252
ironic/objects/runbook.py
Normal file
252
ironic/objects/runbook.py
Normal file
@ -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)
|
@ -7117,6 +7117,113 @@ ORHMKeXMO8fcK0By7CiMKwHSXCoEQgfQhWwpMdSsO8LgHCjh87DQc= """
|
|||||||
self.assertEqual('application/json', ret.content_type)
|
self.assertEqual('application/json', ret.content_type)
|
||||||
self.assertEqual(http_client.BAD_REQUEST, ret.status_code)
|
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):
|
class TestCheckCleanSteps(db_base.DbTestCase):
|
||||||
def test__check_clean_steps_not_list(self):
|
def test__check_clean_steps_not_list(self):
|
||||||
|
@ -160,6 +160,12 @@ class TestV1Routing(api_base.BaseApiTest):
|
|||||||
'volume': [
|
'volume': [
|
||||||
{'href': 'http://localhost/v1/volume/', 'rel': 'self'},
|
{'href': 'http://localhost/v1/volume/', 'rel': 'self'},
|
||||||
{'href': 'http://localhost/volume/', 'rel': 'bookmark'}
|
{'href': 'http://localhost/volume/', 'rel': 'bookmark'}
|
||||||
|
],
|
||||||
|
'runbooks': [
|
||||||
|
{'href': 'http://localhost/v1/runbooks/',
|
||||||
|
'rel': 'self'},
|
||||||
|
{'href': 'http://localhost/runbooks/',
|
||||||
|
'rel': 'bookmark'}
|
||||||
]
|
]
|
||||||
}, response)
|
}, response)
|
||||||
|
|
||||||
|
1126
ironic/tests/unit/api/controllers/v1/test_runbook.py
Normal file
1126
ironic/tests/unit/api/controllers/v1/test_runbook.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -298,6 +298,7 @@ class TestRBACModelBeforeScopesBase(TestACLBase):
|
|||||||
# false positives with test runners.
|
# false positives with test runners.
|
||||||
db_utils.create_test_node(
|
db_utils.create_test_node(
|
||||||
uuid='18a552fb-dcd2-43bf-9302-e4c93287be11')
|
uuid='18a552fb-dcd2-43bf-9302-e4c93287be11')
|
||||||
|
fake_db_runbook = db_utils.create_test_runbook()
|
||||||
self.format_data.update({
|
self.format_data.update({
|
||||||
'node_ident': fake_db_node['uuid'],
|
'node_ident': fake_db_node['uuid'],
|
||||||
'allocated_node_ident': fake_db_node_alloced['uuid'],
|
'allocated_node_ident': fake_db_node_alloced['uuid'],
|
||||||
@ -314,6 +315,7 @@ class TestRBACModelBeforeScopesBase(TestACLBase):
|
|||||||
'driver_name': 'fake-driverz',
|
'driver_name': 'fake-driverz',
|
||||||
'bios_setting': fake_setting,
|
'bios_setting': fake_setting,
|
||||||
'trait': fake_trait,
|
'trait': fake_trait,
|
||||||
|
'runbook_ident': fake_db_runbook['uuid'],
|
||||||
'volume_target_ident': fake_db_volume_target['uuid'],
|
'volume_target_ident': fake_db_volume_target['uuid'],
|
||||||
'volume_connector_ident': fake_db_volume_connector['uuid'],
|
'volume_connector_ident': fake_db_volume_connector['uuid'],
|
||||||
'history_ident': fake_history['uuid'],
|
'history_ident': fake_history['uuid'],
|
||||||
@ -391,6 +393,9 @@ class TestRBACProjectScoped(TestACLBase):
|
|||||||
lessee_project_id = 'f11853c7-fa9c-4db3-a477-c9d8e0dbbf13'
|
lessee_project_id = 'f11853c7-fa9c-4db3-a477-c9d8e0dbbf13'
|
||||||
unowned_node = db_utils.create_test_node(chassis_id=None)
|
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
|
# owned node - since the tests use the same node for
|
||||||
# owner/lesse checks
|
# owner/lesse checks
|
||||||
owned_node = db_utils.create_test_node(
|
owned_node = db_utils.create_test_node(
|
||||||
@ -496,6 +501,7 @@ class TestRBACProjectScoped(TestACLBase):
|
|||||||
'vif_ident': fake_vif_port_id,
|
'vif_ident': fake_vif_port_id,
|
||||||
'ind_component': 'component',
|
'ind_component': 'component',
|
||||||
'ind_ident': 'magic_light',
|
'ind_ident': 'magic_light',
|
||||||
|
'runbook_ident': fake_db_runbook['uuid'],
|
||||||
'owner_port_ident': owned_node_port['uuid'],
|
'owner_port_ident': owned_node_port['uuid'],
|
||||||
'other_port_ident': other_port['uuid'],
|
'other_port_ident': other_port['uuid'],
|
||||||
'owner_portgroup_ident': owner_pgroup['uuid'],
|
'owner_portgroup_ident': owner_pgroup['uuid'],
|
||||||
|
@ -3978,3 +3978,314 @@ service_cannot_get_firmware_components:
|
|||||||
method: get
|
method: get
|
||||||
headers: *service_headers
|
headers: *service_headers
|
||||||
assert_status: 404
|
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
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
values:
|
values:
|
||||||
skip_reason: "These are fake reference values for YAML templating"
|
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
|
# System scoped admin token
|
||||||
admin_headers: &admin_headers
|
admin_headers: &admin_headers
|
||||||
X-Auth-Token: 'baremetal-admin-token'
|
X-Auth-Token: 'baremetal-admin-token'
|
||||||
@ -2584,3 +2589,186 @@ nodes_firmware_component_get_reader:
|
|||||||
method: get
|
method: get
|
||||||
headers: *reader_headers
|
headers: *reader_headers
|
||||||
assert_status: 200
|
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
|
||||||
|
@ -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 node as node_controller
|
||||||
from ironic.api.controllers.v1 import port as port_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 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 utils as api_utils
|
||||||
from ironic.api.controllers.v1 import volume_connector as vc_controller
|
from ironic.api.controllers.v1 import volume_connector as vc_controller
|
||||||
from ironic.api.controllers.v1 import volume_target as vt_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'])
|
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):
|
def post_get_test_deploy_template(**kw):
|
||||||
"""Return a DeployTemplate object with appropriate attributes."""
|
"""Return a DeployTemplate object with appropriate attributes."""
|
||||||
return deploy_template_post_data(**kw)
|
return deploy_template_post_data(**kw)
|
||||||
|
|
||||||
|
|
||||||
|
def post_get_test_runbook(**kw):
|
||||||
|
"""Return a Runbook object with appropriate attributes."""
|
||||||
|
return runbook_post_data(**kw)
|
||||||
|
@ -101,7 +101,7 @@ class ReleaseMappingsTestCase(base.TestCase):
|
|||||||
# NodeBase is also excluded as it is covered by Node.
|
# NodeBase is also excluded as it is covered by Node.
|
||||||
exceptions = set(['NodeTag', 'ConductorHardwareInterfaces',
|
exceptions = set(['NodeTag', 'ConductorHardwareInterfaces',
|
||||||
'NodeTrait', 'DeployTemplateStep',
|
'NodeTrait', 'DeployTemplateStep',
|
||||||
'NodeBase'])
|
'NodeBase', 'RunbookStep'])
|
||||||
model_names -= exceptions
|
model_names -= exceptions
|
||||||
# NodeTrait maps to two objects
|
# NodeTrait maps to two objects
|
||||||
model_names |= set(['Trait', 'TraitList'])
|
model_names |= set(['Trait', 'TraitList'])
|
||||||
|
@ -1169,6 +1169,152 @@ class MigrationCheckersMixin(object):
|
|||||||
self.assertIsInstance(deploy_templates.c.extra.type,
|
self.assertIsInstance(deploy_templates.c.extra.type,
|
||||||
sqlalchemy.types.TEXT)
|
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):
|
def _check_ce6c4b3cf5a2(self, engine, data):
|
||||||
allocations = db_utils.get_table(engine, 'allocations')
|
allocations = db_utils.get_table(engine, 'allocations')
|
||||||
col_names = [column.name for column in allocations.c]
|
col_names = [column.name for column in allocations.c]
|
||||||
|
207
ironic/tests/unit/db/test_runbooks.py
Normal file
207
ironic/tests/unit/db/test_runbooks.py
Normal file
@ -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)
|
@ -32,6 +32,7 @@ from ironic.objects import node_history
|
|||||||
from ironic.objects import node_inventory
|
from ironic.objects import node_inventory
|
||||||
from ironic.objects import port
|
from ironic.objects import port
|
||||||
from ironic.objects import portgroup
|
from ironic.objects import portgroup
|
||||||
|
from ironic.objects import runbook
|
||||||
from ironic.objects import trait
|
from ironic.objects import trait
|
||||||
from ironic.objects import volume_connector
|
from ironic.objects import volume_connector
|
||||||
from ironic.objects import volume_target
|
from ironic.objects import volume_target
|
||||||
@ -673,6 +674,59 @@ def create_test_deploy_template(**kw):
|
|||||||
return dbapi.create_deploy_template(template)
|
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):
|
def get_test_history(**kw):
|
||||||
return {
|
return {
|
||||||
'id': kw.get('id', 345),
|
'id': kw.get('id', 345),
|
||||||
|
@ -724,6 +724,9 @@ expected_object_fingerprints = {
|
|||||||
'NodeInventory': '1.0-97692fec24e20ab02022b9db54e8f539',
|
'NodeInventory': '1.0-97692fec24e20ab02022b9db54e8f539',
|
||||||
'FirmwareComponent': '1.0-0e0720dab959e20247bbcfd5f28958c5',
|
'FirmwareComponent': '1.0-0e0720dab959e20247bbcfd5f28958c5',
|
||||||
'FirmwareComponentList': '1.0-33a2e1bb91ad4082f9f63429b77c1244',
|
'FirmwareComponentList': '1.0-33a2e1bb91ad4082f9f63429b77c1244',
|
||||||
|
'Runbook': '1.0-7a9c65b49b5f7b45686b6a674e703629',
|
||||||
|
'RunbookCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
|
||||||
|
'RunbookCRUDPayload': '1.0-f0c97f4ff29eb3401e53b34550a95e30',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -358,6 +358,41 @@ def get_payloads_with_schemas(from_module):
|
|||||||
return payloads
|
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):
|
class SchemasTestMixIn(object):
|
||||||
def _check_payload_schemas(self, from_module, fields):
|
def _check_payload_schemas(self, from_module, fields):
|
||||||
"""Assert that the Payload SCHEMAs have the expected properties.
|
"""Assert that the Payload SCHEMAs have the expected properties.
|
||||||
|
19
releasenotes/notes/add-runbooks-38c3efa97ace8c67.yaml
Normal file
19
releasenotes/notes/add-runbooks-38c3efa97ace8c67.yaml
Normal file
@ -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/<node>/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.
|
Loading…
Reference in New Issue
Block a user