Add CLI support for runbooks

Enable CRUD support, manual cleaning and servicing with runbooks
from the command line interface.

Demo Video: https://youtu.be/00PJS4SXFYQ

Change-Id: Iec672c505a245991db72afbb9b668220f845ca81
This commit is contained in:
cid 2024-07-11 18:48:14 +01:00 committed by Jay Faulkner
parent 85542f0caf
commit 1035b2b238
13 changed files with 1462 additions and 29 deletions

View File

@ -37,7 +37,7 @@ from ironicclient import exc
# http://specs.openstack.org/openstack/ironic-specs/specs/kilo/api-microversions.html # noqa
# for full details.
DEFAULT_VER = '1.9'
LAST_KNOWN_API_VERSION = 88
LAST_KNOWN_API_VERSION = 92
LATEST_VERSION = '1.{}'.format(LAST_KNOWN_API_VERSION)
LOG = logging.getLogger(__name__)

View File

@ -214,7 +214,7 @@ def common_params_for_list(args, fields, field_labels):
def common_filters(marker=None, limit=None, sort_key=None, sort_dir=None,
fields=None, detail=False):
fields=None, detail=False, project=None, public=None):
"""Generate common filters for any list request.
:param marker: entity ID from which to start returning entities.
@ -237,6 +237,10 @@ def common_filters(marker=None, limit=None, sort_key=None, sort_dir=None,
filters.append('sort_key=%s' % sort_key)
if sort_dir is not None:
filters.append('sort_dir=%s' % sort_dir)
if project is not None:
filters.append('project=%s' % project)
if public is not None:
filters.append('public=True')
if fields is not None:
filters.append('fields=%s' % ','.join(fields))
if detail:

View File

@ -80,6 +80,8 @@ class ProvisionStateBaremetalNode(command.Command):
baremetal_client = self.app.client_manager.baremetal
runbook = getattr(parsed_args, 'runbook', None)
clean_steps = getattr(parsed_args, 'clean_steps', None)
clean_steps = utils.handle_json_arg(clean_steps, 'clean steps')
@ -109,7 +111,8 @@ class ProvisionStateBaremetalNode(command.Command):
cleansteps=clean_steps,
deploysteps=deploy_steps,
rescue_password=rescue_password,
servicesteps=service_steps)
servicesteps=service_steps,
runbook=runbook)
class ProvisionStateWithWait(ProvisionStateBaremetalNode):
@ -289,18 +292,22 @@ class CleanBaremetalNode(ProvisionStateWithWait):
def get_parser(self, prog_name):
parser = super(CleanBaremetalNode, self).get_parser(prog_name)
clean_group = parser.add_mutually_exclusive_group(required=True)
parser.add_argument(
clean_group.add_argument(
'--clean-steps',
metavar='<clean-steps>',
required=True,
default=None,
help=_("The clean steps. May be the path to a YAML file "
"containing the clean steps; OR '-', with the clean steps "
"being read from standard input; OR a JSON string. The "
"value should be a list of clean-step dictionaries; each "
"dictionary should have keys 'interface' and 'step', and "
"optional key 'args'."))
clean_group.add_argument(
'--runbook',
metavar='<runbook>',
help=_("The identifier of a predefined runbook to use for "
"cleaning."))
return parser
@ -312,18 +319,22 @@ class ServiceBaremetalNode(ProvisionStateWithWait):
def get_parser(self, prog_name):
parser = super(ServiceBaremetalNode, self).get_parser(prog_name)
service_group = parser.add_mutually_exclusive_group(required=True)
parser.add_argument(
service_group.add_argument(
'--service-steps',
metavar='<service-steps>',
required=True,
default=None,
help=_("The service steps. May be the path to a YAML file "
"containing the service steps; OR '-', with the service "
" steps being read from standard input; OR a JSON string. "
"The value should be a list of service-step dictionaries; "
"each dictionary should have keys 'interface' and 'step', "
"and optional key 'args'."))
service_group.add_argument(
'--runbook',
metavar='<runbook>',
help=_("The identifier of a predefined runbook to use for "
"servicing."))
return parser

View File

@ -0,0 +1,403 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
import itertools
import json
import logging
from osc_lib.command import command
from osc_lib import utils as oscutils
from ironicclient.common.i18n import _
from ironicclient.common import utils
from ironicclient import exc
from ironicclient.v1 import resource_fields as res_fields
_RUNBOOK_STEPS_HELP = _(
"The runbook steps. May be the path to a YAML file containing the "
"runbook steps; OR '-', with the runbook steps being read from standard "
"input; OR a JSON string. The value should be a list of runbook step "
"dictionaries; each dictionary should have keys 'interface', 'step', "
"'args' and 'order'.")
class CreateBaremetalRunbook(command.ShowOne):
"""Create a new runbook"""
log = logging.getLogger(__name__ + ".CreateBaremetalRunbook")
def get_parser(self, prog_name):
parser = super(CreateBaremetalRunbook, self).get_parser(
prog_name)
parser.add_argument(
'--name',
metavar='<name>',
required=True,
help=_('Unique name for this runbook. Must be a valid '
'trait name')
)
parser.add_argument(
'--uuid',
dest='uuid',
metavar='<uuid>',
help=_('UUID of the runbook.'))
parser.add_argument(
'--public',
metavar='<public>',
help=_('Whether the runbook will be private or public.')
)
parser.add_argument(
'--owner',
metavar='<owner>',
help=_('Owner of the runbook.')
)
parser.add_argument(
'--extra',
metavar="<key=value>",
action='append',
help=_("Record arbitrary key/value metadata. "
"Can be specified multiple times."))
parser.add_argument(
'--steps',
metavar="<steps>",
required=True,
help=_RUNBOOK_STEPS_HELP
)
return parser
def take_action(self, parsed_args):
self.log.debug("take_action(%s)", parsed_args)
baremetal_client = self.app.client_manager.baremetal
steps = utils.handle_json_arg(parsed_args.steps, 'runbook steps')
field_list = ['name', 'uuid', 'owner', 'public', 'extra']
fields = dict((k, v) for (k, v) in vars(parsed_args).items()
if k in field_list and v is not None)
fields = utils.args_array_to_dict(fields, 'extra')
runbook = baremetal_client.runbook.create(steps=steps,
**fields)
data = dict([(f, getattr(runbook, f, '')) for f in
res_fields.RUNBOOK_DETAILED_RESOURCE.fields])
return self.dict2columns(data)
class ShowBaremetalRunbook(command.ShowOne):
"""Show baremetal runbook details."""
log = logging.getLogger(__name__ + ".ShowBaremetalRunbook")
def get_parser(self, prog_name):
parser = super(ShowBaremetalRunbook, self).get_parser(prog_name)
parser.add_argument(
"runbook",
metavar="<runbook>",
help=_("Name or UUID of the runbook.")
)
parser.add_argument(
'--fields',
nargs='+',
dest='fields',
metavar='<field>',
action='append',
choices=res_fields.RUNBOOK_DETAILED_RESOURCE.fields,
default=[],
help=_("One or more runbook fields. Only these fields "
"will be fetched from the server.")
)
return parser
def take_action(self, parsed_args):
self.log.debug("take_action(%s)", parsed_args)
baremetal_client = self.app.client_manager.baremetal
fields = list(itertools.chain.from_iterable(parsed_args.fields))
fields = fields if fields else None
runbook = baremetal_client.runbook.get(
parsed_args.runbook, fields=fields)._info
runbook.pop("links", None)
return zip(*sorted(runbook.items()))
class SetBaremetalRunbook(command.Command):
"""Set baremetal runbook properties."""
log = logging.getLogger(__name__ + ".SetBaremetalRunbook")
def get_parser(self, prog_name):
parser = super(SetBaremetalRunbook, self).get_parser(prog_name)
parser.add_argument(
'runbook',
metavar='<runbook>',
help=_("Name or UUID of the runbook")
)
parser.add_argument(
'--name',
metavar='<name>',
help=_('Set unique name of the runbook. Must be a valid '
'trait name.')
)
parser.add_argument(
'--public',
metavar='<public>',
help=_('Make a private runbook public.')
)
parser.add_argument(
'--owner',
metavar='<owner>',
help=_('Set owner of a runbook.')
)
parser.add_argument(
'--steps',
metavar="<steps>",
help=_RUNBOOK_STEPS_HELP
)
parser.add_argument(
"--extra",
metavar="<key=value>",
action='append',
help=_('Extra to set on this baremetal runbook '
'(repeat option to set multiple extras).'),
)
return parser
def take_action(self, parsed_args):
self.log.debug("take_action(%s)", parsed_args)
baremetal_client = self.app.client_manager.baremetal
properties = []
if parsed_args.name:
name = ["name=%s" % parsed_args.name]
properties.extend(utils.args_array_to_patch('add', name))
if parsed_args.owner:
owner = ["owner=%s" % parsed_args.owner]
properties.extend(utils.args_array_to_patch('add', owner))
if parsed_args.public:
public = ["public=%s" % parsed_args.public]
properties.extend(utils.args_array_to_patch('add', public))
if parsed_args.steps:
steps = utils.handle_json_arg(parsed_args.steps, 'runbook steps')
steps = ["steps=%s" % json.dumps(steps)]
properties.extend(utils.args_array_to_patch('add', steps))
if parsed_args.extra:
properties.extend(utils.args_array_to_patch(
'add', ['extra/' + x for x in parsed_args.extra]))
if properties:
baremetal_client.runbook.update(parsed_args.runbook,
properties)
else:
self.log.warning("Please specify what to set.")
class UnsetBaremetalRunbook(command.Command):
"""Unset baremetal runbook properties."""
log = logging.getLogger(__name__ + ".UnsetBaremetalRunbook")
def get_parser(self, prog_name):
parser = super(UnsetBaremetalRunbook, self).get_parser(
prog_name)
parser.add_argument(
'runbook',
metavar='<runbook>',
help=_("Name or UUID of the runbook")
)
parser.add_argument(
'--name',
action='store_true',
help=_('Unset the name of the runbook.')
)
parser.add_argument(
'--public',
dest='public',
action='store_true',
help=_('Make a public runbook private.')
)
parser.add_argument(
'--owner',
dest='owner',
action='store_true',
help=_('Unset owner of a runbook.')
)
parser.add_argument(
"--step",
metavar="<key>",
action='append',
help=_('Step to unset on this baremetal runbook '
'(repeat option to unset multiple steps).'),
)
parser.add_argument(
"--extra",
metavar="<key>",
action='append',
help=_('Extra to unset on this baremetal runbook '
'(repeat option to unset multiple extras).'),
)
return parser
def take_action(self, parsed_args):
self.log.debug("take_action(%s)", parsed_args)
baremetal_client = self.app.client_manager.baremetal
properties = []
for field in ['name', 'owner', 'public']:
if getattr(parsed_args, field):
properties.extend(utils.args_array_to_patch('remove', [field]))
if parsed_args.extra:
properties.extend(utils.args_array_to_patch('remove',
['extra/' + x for x in parsed_args.extra]))
if parsed_args.step:
properties.extend(utils.args_array_to_patch('remove',
['step/' + x for x in parsed_args.step]))
if properties:
baremetal_client.runbook.update(parsed_args.runbook,
properties)
else:
self.log.warning("Please specify what to unset.")
class DeleteBaremetalRunbook(command.Command):
"""Delete runbook(s)."""
log = logging.getLogger(__name__ + ".DeleteBaremetalRunbook")
def get_parser(self, prog_name):
parser = super(DeleteBaremetalRunbook, self).get_parser(
prog_name)
parser.add_argument(
"runbooks",
metavar="<runbook>",
nargs="+",
help=_("Name(s) or UUID(s) of the runbook(s) to delete.")
)
return parser
def take_action(self, parsed_args):
self.log.debug("take_action(%s)", parsed_args)
baremetal_client = self.app.client_manager.baremetal
failures = []
for runbook in parsed_args.runbooks:
try:
baremetal_client.runbook.delete(runbook)
print(_('Deleted runbook %s') % runbook)
except exc.ClientException as e:
failures.append(_("Failed to delete runbook "
"%(runbook)s: %(error)s")
% {'runbook': runbook, 'error': e})
if failures:
raise exc.ClientException("\n".join(failures))
class ListBaremetalRunbook(command.Lister):
"""List baremetal runbooks."""
log = logging.getLogger(__name__ + ".ListBaremetalRunbook")
def get_parser(self, prog_name):
parser = super(ListBaremetalRunbook, self).get_parser(prog_name)
parser.add_argument(
'--limit',
metavar='<limit>',
type=int,
help=_('Maximum number of runbooks to return per request, '
'0 for no limit. Default is the maximum number used '
'by the Baremetal API Service.')
)
parser.add_argument(
'--marker',
metavar='<runbook>',
help=_('Runbook UUID (for example, of the last runbook '
'in the list from a previous request). Returns '
'the list of runbooks after this UUID.')
)
parser.add_argument(
'--sort',
metavar="<key>[:<direction>]",
help=_('Sort output by specified runbook fields and '
'directions (asc or desc) (default: asc). Multiple fields '
'and directions can be specified, separated by comma.')
)
display_group = parser.add_mutually_exclusive_group()
display_group.add_argument(
'--long',
dest='detail',
action='store_true',
default=False,
help=_("Show detailed information about runbooks.")
)
display_group.add_argument(
'--fields',
nargs='+',
dest='fields',
metavar='<field>',
action='append',
default=[],
choices=res_fields.RUNBOOK_DETAILED_RESOURCE.fields,
help=_("One or more runbook fields. Only these fields "
"will be fetched from the server. Can not be used when "
"'--long' is specified.")
)
return parser
def take_action(self, parsed_args):
self.log.debug("take_action(%s)", parsed_args)
client = self.app.client_manager.baremetal
columns = res_fields.RUNBOOK_RESOURCE.fields
labels = res_fields.RUNBOOK_RESOURCE.labels
params = {}
if parsed_args.limit is not None and parsed_args.limit < 0:
raise exc.CommandError(
_('Expected non-negative --limit, got %s') %
parsed_args.limit)
params['limit'] = parsed_args.limit
params['marker'] = parsed_args.marker
if parsed_args.detail:
params['detail'] = parsed_args.detail
columns = res_fields.RUNBOOK_DETAILED_RESOURCE.fields
labels = res_fields.RUNBOOK_DETAILED_RESOURCE.labels
elif parsed_args.fields:
params['detail'] = False
fields = itertools.chain.from_iterable(parsed_args.fields)
resource = res_fields.Resource(list(fields))
columns = resource.fields
labels = resource.labels
params['fields'] = columns
self.log.debug("params(%s)", params)
data = client.runbook.list(**params)
data = oscutils.sort_items(data, parsed_args.sort)
return (labels,
(oscutils.get_item_properties(s, columns) for s in data))

View File

@ -235,6 +235,26 @@ DEPLOY_TEMPLATE = {
'steps': baremetal_deploy_template_steps,
'extra': baremetal_deploy_template_extra,
}
baremetal_runbook_uuid = 'ddd-tttttt-dddd'
baremetal_runbook_name = 'CUSTOM_AWESOME'
baremetal_runbook_owner = 'some_user'
baremetal_runbook_public = False
baremetal_runbook_steps = json.dumps([{
'interface': 'raid',
'step': 'create_configuration',
'args': {},
'order': 1
}])
baremetal_runbook_extra = {'key1': 'value1', 'key2': 'value2'}
RUNBOOK = {
'uuid': baremetal_runbook_uuid,
'name': baremetal_runbook_name,
'owner': baremetal_runbook_owner,
'public': baremetal_runbook_public,
'steps': baremetal_runbook_steps,
'extra': baremetal_runbook_extra,
}
NODE_HISTORY = [
{
'uuid': 'abcdef1',

View File

@ -59,7 +59,8 @@ class TestAbort(TestBaremetal):
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'abort', cleansteps=None, configdrive=None,
deploysteps=None, rescue_password=None, servicesteps=None)
deploysteps=None, rescue_password=None, servicesteps=None,
runbook=None)
class TestAdopt(TestBaremetal):
@ -83,7 +84,7 @@ class TestAdopt(TestBaremetal):
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'adopt',
cleansteps=None, deploysteps=None, configdrive=None,
rescue_password=None, servicesteps=None)
rescue_password=None, servicesteps=None, runbook=None)
self.baremetal_mock.node.wait_for_provision_state.assert_not_called()
def test_adopt_baremetal_provision_state_active_and_wait(self):
@ -103,7 +104,7 @@ class TestAdopt(TestBaremetal):
test_node.set_provision_state.assert_called_once_with(
'node_uuid', 'adopt',
cleansteps=None, deploysteps=None, configdrive=None,
rescue_password=None, servicesteps=None)
rescue_password=None, servicesteps=None, runbook=None)
test_node.wait_for_provision_state.assert_called_once_with(
['node_uuid'], expected_state='active',
poll_interval=2, timeout=15)
@ -125,7 +126,7 @@ class TestAdopt(TestBaremetal):
test_node.set_provision_state.assert_called_once_with(
'node_uuid', 'adopt',
cleansteps=None, deploysteps=None, configdrive=None,
rescue_password=None, servicesteps=None)
rescue_password=None, servicesteps=None, runbook=None)
test_node.wait_for_provision_state.assert_called_once_with(
['node_uuid'], expected_state='active',
poll_interval=2, timeout=0)
@ -174,7 +175,55 @@ class TestClean(TestBaremetal):
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'clean', cleansteps=steps_dict, configdrive=None,
deploysteps=None, rescue_password=None, servicesteps=None)
deploysteps=None, rescue_password=None, servicesteps=None,
runbook=None)
def test_clean_with_runbook(self):
runbook_name = 'runbook_name'
arglist = ['--runbook', runbook_name, 'node_uuid']
verifylist = [
('runbook', runbook_name),
('provision_state', 'clean'),
('nodes', ['node_uuid']),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.cmd.take_action(parsed_args)
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'clean', cleansteps=None, configdrive=None,
deploysteps=None, rescue_password=None, servicesteps=None,
runbook=runbook_name)
def test_clean_with_runbook_and_steps(self):
runbook_name = 'runbook_name'
steps_dict = {
"clean_steps": [{
"interface": "raid",
"step": "create_configuration",
"args": {"create_nonroot_volumes": False}
}, {
"interface": "deploy",
"step": "erase_devices"
}]
}
steps_json = json.dumps(steps_dict)
arglist = ['--runbook', runbook_name, '--clean-steps', steps_json,
'node_uuid']
verifylist = [
('clean_steps', steps_json),
('runbook', runbook_name),
('provision_state', 'clean'),
('nodes', ['node_uuid']),
]
self.assertRaises(oscutils.ParserException,
self.check_parser,
self.cmd, arglist, verifylist)
class TestService(TestBaremetal):
@ -210,7 +259,54 @@ class TestService(TestBaremetal):
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'service', cleansteps=None, configdrive=None,
deploysteps=None, rescue_password=None, servicesteps=steps_dict)
deploysteps=None, rescue_password=None, servicesteps=steps_dict,
runbook=None)
def test_service_with_runbook(self):
runbook_name = 'runbook_name'
arglist = ['--runbook', runbook_name, 'node_uuid']
verifylist = [
('runbook', runbook_name),
('provision_state', 'service'),
('nodes', ['node_uuid']),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.cmd.take_action(parsed_args)
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'service', cleansteps=None, configdrive=None,
deploysteps=None, rescue_password=None, servicesteps=None,
runbook=runbook_name)
def test_service_with_runbook_and_steps(self):
runbook_name = 'runbook_name'
steps_dict = {
"service_steps": [{
"interface": "raid",
"step": "create_configuration",
"args": {"create_nonroot_volumes": False}
}, {
"interface": "deploy",
"step": "erase_devices"
}]
}
steps_json = json.dumps(steps_dict)
arglist = ['--service-steps', steps_json, '--runbook', runbook_name,
'node_uuid']
verifylist = [
('service_steps', steps_json),
('runbook', runbook_name),
('provision_state', 'service'),
('nodes', ['node_uuid']),
]
self.assertRaises(oscutils.ParserException,
self.check_parser,
self.cmd, arglist, verifylist)
class TestInspect(TestBaremetal):
@ -233,7 +329,8 @@ class TestInspect(TestBaremetal):
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'inspect', cleansteps=None, configdrive=None,
deploysteps=None, rescue_password=None, servicesteps=None)
deploysteps=None, rescue_password=None, servicesteps=None,
runbook=None)
class TestManage(TestBaremetal):
@ -256,7 +353,8 @@ class TestManage(TestBaremetal):
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'manage', cleansteps=None, configdrive=None,
deploysteps=None, rescue_password=None, servicesteps=None)
deploysteps=None, rescue_password=None, servicesteps=None,
runbook=None)
class TestProvide(TestBaremetal):
@ -279,7 +377,8 @@ class TestProvide(TestBaremetal):
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'provide', cleansteps=None, configdrive=None,
deploysteps=None, rescue_password=None, servicesteps=None)
deploysteps=None, rescue_password=None, servicesteps=None,
runbook=None)
class TestRebuild(TestBaremetal):
@ -302,7 +401,8 @@ class TestRebuild(TestBaremetal):
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'rebuild', cleansteps=None, configdrive=None,
deploysteps=None, rescue_password=None, servicesteps=None)
deploysteps=None, rescue_password=None, servicesteps=None,
runbook=None)
class TestUndeploy(TestBaremetal):
@ -325,7 +425,8 @@ class TestUndeploy(TestBaremetal):
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'deleted', cleansteps=None, configdrive=None,
deploysteps=None, rescue_password=None, servicesteps=None)
deploysteps=None, rescue_password=None, servicesteps=None,
runbook=None)
class TestBootdeviceSet(TestBaremetal):
@ -1912,7 +2013,7 @@ class TestDeployBaremetalProvisionState(TestBaremetal):
'node_uuid', 'active',
cleansteps=None, deploysteps=[{"interface": "deploy"}],
configdrive='path/to/drive', rescue_password=None,
servicesteps=None)
servicesteps=None, runbook=None)
def test_deploy_baremetal_provision_state_active_and_configdrive_dict(
self):
@ -1931,7 +2032,7 @@ class TestDeployBaremetalProvisionState(TestBaremetal):
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'active',
cleansteps=None, deploysteps=None, configdrive={'meta_data': {}},
rescue_password=None, servicesteps=None)
rescue_password=None, servicesteps=None, runbook=None)
def test_deploy_no_wait(self):
arglist = ['node_uuid']
@ -1996,7 +2097,7 @@ class TestDeployBaremetalProvisionState(TestBaremetal):
test_node.set_provision_state.assert_has_calls([
mock.call(n, 'active', cleansteps=None, deploysteps=None,
configdrive=None, rescue_password=None,
servicesteps=None)
servicesteps=None, runbook=None)
for n in ['node_uuid', 'node_name']
])
test_node.wait_for_provision_state.assert_called_once_with(
@ -2220,7 +2321,7 @@ class TestRescueBaremetalProvisionState(TestBaremetal):
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'rescue', cleansteps=None, deploysteps=None,
configdrive=None, rescue_password='supersecret',
servicesteps=None)
servicesteps=None, runbook=None)
def test_rescue_baremetal_provision_state_rescue_and_wait(self):
arglist = ['node_uuid',
@ -2412,7 +2513,7 @@ class TestRebuildBaremetalProvisionState(TestBaremetal):
'node_uuid', 'rebuild',
cleansteps=None, deploysteps=[{"interface": "deploy"}],
configdrive='path/to/drive', rescue_password=None,
servicesteps=None)
servicesteps=None, runbook=None)
def test_rebuild_no_wait(self):
arglist = ['node_uuid']
@ -2428,7 +2529,7 @@ class TestRebuildBaremetalProvisionState(TestBaremetal):
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'rebuild',
cleansteps=None, deploysteps=None, configdrive=None,
rescue_password=None, servicesteps=None)
rescue_password=None, servicesteps=None, runbook=None)
self.baremetal_mock.node.wait_for_provision_state.assert_not_called()
@ -2546,7 +2647,8 @@ class TestUnrescueBaremetalProvisionState(TestBaremetal):
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'unrescue', cleansteps=None, deploysteps=None,
configdrive=None, rescue_password=None, servicesteps=None)
configdrive=None, rescue_password=None, servicesteps=None,
runbook=None)
def test_unrescue_baremetal_provision_state_active_and_wait(self):
arglist = ['node_uuid',
@ -4661,7 +4763,8 @@ class TestUnholdBaremetalProvisionState(TestBaremetal):
self.baremetal_mock.node.set_provision_state.assert_called_once_with(
'node_uuid', 'unhold', cleansteps=None, deploysteps=None,
configdrive=None, rescue_password=None, servicesteps=None)
configdrive=None, rescue_password=None, servicesteps=None,
runbook=None)
class TestListFirmwareComponents(TestBaremetal):

View File

@ -0,0 +1,462 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
import copy
import json
from unittest import mock
from osc_lib.tests import utils as osctestutils
from ironicclient import exc
from ironicclient.osc.v1 import baremetal_runbook
from ironicclient.tests.unit.osc.v1 import fakes as baremetal_fakes
class TestBaremetalRunbook(baremetal_fakes.TestBaremetal):
def setUp(self):
super(TestBaremetalRunbook, self).setUp()
self.baremetal_mock = self.app.client_manager.baremetal
self.baremetal_mock.reset_mock()
class TestCreateBaremetalRunbook(TestBaremetalRunbook):
def setUp(self):
super(TestCreateBaremetalRunbook, self).setUp()
self.baremetal_mock.runbook.create.return_value = (
baremetal_fakes.FakeBaremetalResource(
None,
copy.deepcopy(baremetal_fakes.RUNBOOK),
loaded=True,
))
# Get the command object to test
self.cmd = baremetal_runbook.CreateBaremetalRunbook(
self.app, None)
def test_baremetal_runbook_create(self):
arglist = [
'--name', baremetal_fakes.baremetal_runbook_name,
'--steps', baremetal_fakes.baremetal_runbook_steps,
]
verifylist = [
('name', baremetal_fakes.baremetal_runbook_name),
('steps', baremetal_fakes.baremetal_runbook_steps),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.cmd.take_action(parsed_args)
# Set expected values
args = {
'name': baremetal_fakes.baremetal_runbook_name,
'steps': json.loads(
baremetal_fakes.baremetal_runbook_steps),
}
self.baremetal_mock.runbook.create.assert_called_once_with(
**args)
def test_baremetal_runbook_create_uuid(self):
arglist = [
'--name', baremetal_fakes.baremetal_runbook_name,
'--steps', baremetal_fakes.baremetal_runbook_steps,
'--uuid', baremetal_fakes.baremetal_runbook_uuid,
]
verifylist = [
('name', baremetal_fakes.baremetal_runbook_name),
('steps', baremetal_fakes.baremetal_runbook_steps),
('uuid', baremetal_fakes.baremetal_runbook_uuid),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.cmd.take_action(parsed_args)
# Set expected values
args = {
'name': baremetal_fakes.baremetal_runbook_name,
'steps': json.loads(
baremetal_fakes.baremetal_runbook_steps),
'uuid': baremetal_fakes.baremetal_runbook_uuid,
}
self.baremetal_mock.runbook.create.assert_called_once_with(
**args)
def test_baremetal_runbook_create_no_name(self):
arglist = [
'--steps', baremetal_fakes.baremetal_runbook_steps,
]
verifylist = [
('steps', baremetal_fakes.baremetal_runbook_steps),
]
self.assertRaises(osctestutils.ParserException,
self.check_parser,
self.cmd, arglist, verifylist)
self.assertFalse(self.baremetal_mock.runbook.create.called)
def test_baremetal_runbook_create_no_steps(self):
arglist = [
'--name', baremetal_fakes.baremetal_runbook_name,
]
verifylist = [
('name', baremetal_fakes.baremetal_runbook_name),
]
self.assertRaises(osctestutils.ParserException,
self.check_parser,
self.cmd, arglist, verifylist)
self.assertFalse(self.baremetal_mock.runbook.create.called)
class TestShowBaremetalRunbook(TestBaremetalRunbook):
def setUp(self):
super(TestShowBaremetalRunbook, self).setUp()
self.baremetal_mock.runbook.get.return_value = (
baremetal_fakes.FakeBaremetalResource(
None,
copy.deepcopy(baremetal_fakes.RUNBOOK),
loaded=True))
self.cmd = baremetal_runbook.ShowBaremetalRunbook(
self.app, None)
def test_baremetal_runbook_show(self):
arglist = [baremetal_fakes.baremetal_runbook_uuid]
verifylist = [('runbook',
baremetal_fakes.baremetal_runbook_uuid)]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
# Set expected values
args = [baremetal_fakes.baremetal_runbook_uuid]
self.baremetal_mock.runbook.get.assert_called_with(
*args, fields=None)
collist = (
'extra',
'name',
'owner',
'public',
'steps',
'uuid')
self.assertEqual(collist, columns)
datalist = (
baremetal_fakes.baremetal_runbook_extra,
baremetal_fakes.baremetal_runbook_name,
baremetal_fakes.baremetal_runbook_owner,
baremetal_fakes.baremetal_runbook_public,
baremetal_fakes.baremetal_runbook_steps,
baremetal_fakes.baremetal_runbook_uuid)
self.assertEqual(datalist, tuple(data))
def test_baremetal_runbook_show_no_template(self):
arglist = []
verifylist = []
self.assertRaises(osctestutils.ParserException,
self.check_parser,
self.cmd, arglist, verifylist)
class TestBaremetalRunbookSet(TestBaremetalRunbook):
def setUp(self):
super(TestBaremetalRunbookSet, self).setUp()
self.baremetal_mock.runbook.update.return_value = (
baremetal_fakes.FakeBaremetalResource(
None,
copy.deepcopy(baremetal_fakes.RUNBOOK),
loaded=True))
self.cmd = baremetal_runbook.SetBaremetalRunbook(
self.app, None)
def test_baremetal_runbook_set_name(self):
new_name = 'foo'
arglist = [
baremetal_fakes.baremetal_runbook_uuid,
'--name', new_name]
verifylist = [
('runbook', baremetal_fakes.baremetal_runbook_uuid),
('name', new_name)]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.cmd.take_action(parsed_args)
self.baremetal_mock.runbook.update.assert_called_once_with(
baremetal_fakes.baremetal_runbook_uuid,
[{'path': '/name', 'value': new_name, 'op': 'add'}])
def test_baremetal_runbook_set_steps(self):
arglist = [
baremetal_fakes.baremetal_runbook_uuid,
'--steps', baremetal_fakes.baremetal_runbook_steps]
verifylist = [
('runbook', baremetal_fakes.baremetal_runbook_uuid),
('steps', baremetal_fakes.baremetal_runbook_steps)]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
expected_steps = json.loads(
baremetal_fakes.baremetal_runbook_steps)
self.cmd.take_action(parsed_args)
self.baremetal_mock.runbook.update.assert_called_once_with(
baremetal_fakes.baremetal_runbook_uuid,
[{'path': '/steps', 'value': expected_steps, 'op': 'add'}])
def test_baremetal_runbook_set_no_options(self):
arglist = []
verifylist = []
self.assertRaises(osctestutils.ParserException,
self.check_parser,
self.cmd, arglist, verifylist)
class TestBaremetalRunbookUnset(TestBaremetalRunbook):
def setUp(self):
super(TestBaremetalRunbookUnset, self).setUp()
self.baremetal_mock.runbook.update.return_value = (
baremetal_fakes.FakeBaremetalResource(
None,
copy.deepcopy(baremetal_fakes.RUNBOOK),
loaded=True))
self.cmd = baremetal_runbook.UnsetBaremetalRunbook(
self.app, None)
def test_baremetal_runbook_unset_extra(self):
arglist = [
baremetal_fakes.baremetal_runbook_uuid, '--extra', 'key1']
verifylist = [('runbook',
baremetal_fakes.baremetal_runbook_uuid),
('extra', ['key1'])]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.cmd.take_action(parsed_args)
self.baremetal_mock.runbook.update.assert_called_once_with(
baremetal_fakes.baremetal_runbook_uuid,
[{'path': '/extra/key1', 'op': 'remove'}])
def test_baremetal_runbook_unset_multiple_extras(self):
arglist = [
baremetal_fakes.baremetal_runbook_uuid,
'--extra', 'key1', '--extra', 'key2']
verifylist = [('runbook',
baremetal_fakes.baremetal_runbook_uuid),
('extra', ['key1', 'key2'])]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.cmd.take_action(parsed_args)
self.baremetal_mock.runbook.update.assert_called_once_with(
baremetal_fakes.baremetal_runbook_uuid,
[{'path': '/extra/key1', 'op': 'remove'},
{'path': '/extra/key2', 'op': 'remove'}])
def test_baremetal_runbook_unset_no_options(self):
arglist = []
verifylist = []
self.assertRaises(osctestutils.ParserException,
self.check_parser,
self.cmd, arglist, verifylist)
def test_baremetal_runbook_unset_no_property(self):
uuid = baremetal_fakes.baremetal_runbook_uuid
arglist = [uuid]
verifylist = [('runbook', uuid)]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.cmd.take_action(parsed_args)
self.assertFalse(self.baremetal_mock.runbook.update.called)
class TestBaremetalRunbookDelete(TestBaremetalRunbook):
def setUp(self):
super(TestBaremetalRunbookDelete, self).setUp()
self.cmd = baremetal_runbook.DeleteBaremetalRunbook(
self.app, None)
def test_baremetal_runbook_delete(self):
arglist = ['zzz-zzzzzz-zzzz']
verifylist = []
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.cmd.take_action(parsed_args)
args = 'zzz-zzzzzz-zzzz'
self.baremetal_mock.runbook.delete.assert_called_with(args)
def test_baremetal_runbook_delete_multiple(self):
arglist = ['zzz-zzzzzz-zzzz', 'fakename']
verifylist = []
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.cmd.take_action(parsed_args)
args = ['zzz-zzzzzz-zzzz', 'fakename']
self.baremetal_mock.runbook.delete.assert_has_calls(
[mock.call(x) for x in args])
self.assertEqual(
2, self.baremetal_mock.runbook.delete.call_count)
def test_baremetal_runbook_delete_multiple_with_fail(self):
arglist = ['zzz-zzzzzz-zzzz', 'badname']
verifylist = []
self.baremetal_mock.runbook.delete.side_effect = [
'', exc.ClientException]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.assertRaises(exc.ClientException,
self.cmd.take_action,
parsed_args)
args = ['zzz-zzzzzz-zzzz', 'badname']
self.baremetal_mock.runbook.delete.assert_has_calls(
[mock.call(x) for x in args])
self.assertEqual(
2, self.baremetal_mock.runbook.delete.call_count)
def test_baremetal_runbook_delete_no_template(self):
arglist = []
verifylist = []
self.assertRaises(osctestutils.ParserException,
self.check_parser,
self.cmd, arglist, verifylist)
class TestBaremetalRunbookList(TestBaremetalRunbook):
def setUp(self):
super(TestBaremetalRunbookList, self).setUp()
self.baremetal_mock.runbook.list.return_value = [
baremetal_fakes.FakeBaremetalResource(
None,
copy.deepcopy(baremetal_fakes.RUNBOOK),
loaded=True)
]
self.cmd = baremetal_runbook.ListBaremetalRunbook(
self.app, None)
def test_baremetal_runbook_list(self):
arglist = []
verifylist = []
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
kwargs = {
'marker': None,
'limit': None}
self.baremetal_mock.runbook.list.assert_called_with(**kwargs)
collist = (
"UUID",
"Name")
self.assertEqual(collist, columns)
datalist = ((
baremetal_fakes.baremetal_runbook_uuid,
baremetal_fakes.baremetal_runbook_name
), )
self.assertEqual(datalist, tuple(data))
def test_baremetal_runbook_list_long(self):
arglist = ['--long']
verifylist = [('detail', True)]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
kwargs = {
'detail': True,
'marker': None,
'limit': None,
}
self.baremetal_mock.runbook.list.assert_called_with(**kwargs)
collist = ('UUID',
'Name',
'Owner',
'Public',
'Steps',
'Extra',
'Created At',
'Updated At')
self.assertEqual(collist, columns)
datalist = ((
baremetal_fakes.baremetal_runbook_uuid,
baremetal_fakes.baremetal_runbook_name,
baremetal_fakes.baremetal_runbook_owner,
baremetal_fakes.baremetal_runbook_public,
baremetal_fakes.baremetal_runbook_steps,
baremetal_fakes.baremetal_runbook_extra,
'',
'',
), )
self.assertEqual(datalist, tuple(data))
def test_baremetal_runbook_list_fields(self):
arglist = ['--fields', 'uuid', 'steps']
verifylist = [('fields', [['uuid', 'steps']])]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.cmd.take_action(parsed_args)
kwargs = {
'marker': None,
'limit': None,
'detail': False,
'fields': ('uuid', 'steps')
}
self.baremetal_mock.runbook.list.assert_called_with(**kwargs)
def test_baremetal_runbook_list_fields_multiple(self):
arglist = ['--fields', 'uuid', 'name', '--fields', 'steps']
verifylist = [('fields', [['uuid', 'name'], ['steps']])]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.cmd.take_action(parsed_args)
kwargs = {
'marker': None,
'limit': None,
'detail': False,
'fields': ('uuid', 'name', 'steps')
}
self.baremetal_mock.runbook.list.assert_called_with(**kwargs)
def test_baremetal_runbook_list_invalid_fields(self):
arglist = ['--fields', 'uuid', 'invalid']
verifylist = [('fields', [['uuid', 'invalid']])]
self.assertRaises(osctestutils.ParserException,
self.check_parser,
self.cmd, arglist, verifylist)

View File

@ -0,0 +1,291 @@
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import copy
import testtools
from testtools.matchers import HasLength
from ironicclient import exc
from ironicclient.tests.unit import utils
import ironicclient.v1.runbook
RUNBOOK = {'uuid': '11111111-2222-3333-4444-555555555555',
'name': 'CUSTOM_RUNBOOK',
'steps': {},
'extra': {}}
RUNBOOK2 = {'uuid': '55555555-4444-3333-2222-111111111111',
'name': 'CUSTOM_RUNBOOK2',
'steps': {},
'extra': {}}
CREATE_RUNBOOK = copy.deepcopy(RUNBOOK)
del CREATE_RUNBOOK['uuid']
CREATE_RUNBOOK_WITH_UUID = copy.deepcopy(RUNBOOK)
UPDATED_RUNBOOK = copy.deepcopy(RUNBOOK)
NEW_NAME = 'CUSTOM_RUNBOOK3'
UPDATED_RUNBOOK['name'] = NEW_NAME
fake_responses = {
'/v1/runbooks':
{
'GET': (
{},
{"runbooks": [RUNBOOK]},
),
'POST': (
{},
CREATE_RUNBOOK,
),
},
'/v1/runbooks/?detail=True':
{
'GET': (
{},
{"runbooks": [RUNBOOK]},
),
},
'/v1/runbooks/?fields=uuid,name':
{
'GET': (
{},
{"runbooks": [RUNBOOK]},
),
},
'/v1/runbooks/%s' % RUNBOOK['uuid']:
{
'GET': (
{},
RUNBOOK,
),
'DELETE': (
{},
None,
),
'PATCH': (
{},
UPDATED_RUNBOOK,
),
},
'/v1/runbooks/%s?fields=uuid,name' % RUNBOOK['uuid']:
{
'GET': (
{},
RUNBOOK,
),
},
}
fake_responses_pagination = {
'/v1/runbooks':
{
'GET': (
{},
{"runbooks": [RUNBOOK],
"next": "http://127.0.0.1:6385/v1/runbooks/?limit=1"}
),
},
'/v1/runbooks/?limit=1':
{
'GET': (
{},
{"runbooks": [RUNBOOK2]}
),
},
'/v1/runbooks/?marker=%s' % RUNBOOK['uuid']:
{
'GET': (
{},
{"runbooks": [RUNBOOK2]}
),
},
}