From 1035b2b238e888eec705be434d7f7e3fe1d13c4c Mon Sep 17 00:00:00 2001 From: cid Date: Thu, 11 Jul 2024 18:48:14 +0100 Subject: [PATCH] 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 --- ironicclient/common/http.py | 2 +- ironicclient/common/utils.py | 6 +- ironicclient/osc/v1/baremetal_node.py | 25 +- ironicclient/osc/v1/baremetal_runbook.py | 403 +++++++++++++++ ironicclient/tests/unit/osc/v1/fakes.py | 20 + .../tests/unit/osc/v1/test_baremetal_node.py | 141 +++++- .../unit/osc/v1/test_baremetal_runbook.py | 462 ++++++++++++++++++ ironicclient/tests/unit/v1/test_runbook.py | 291 +++++++++++ ironicclient/v1/client.py | 2 + ironicclient/v1/node.py | 7 +- ironicclient/v1/resource_fields.py | 20 + ironicclient/v1/runbook.py | 106 ++++ setup.cfg | 6 + 13 files changed, 1462 insertions(+), 29 deletions(-) create mode 100644 ironicclient/osc/v1/baremetal_runbook.py create mode 100644 ironicclient/tests/unit/osc/v1/test_baremetal_runbook.py create mode 100644 ironicclient/tests/unit/v1/test_runbook.py create mode 100644 ironicclient/v1/runbook.py diff --git a/ironicclient/common/http.py b/ironicclient/common/http.py index 9cad35560..39fb65525 100644 --- a/ironicclient/common/http.py +++ b/ironicclient/common/http.py @@ -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__) diff --git a/ironicclient/common/utils.py b/ironicclient/common/utils.py index 32db2b870..498dfabe4 100644 --- a/ironicclient/common/utils.py +++ b/ironicclient/common/utils.py @@ -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: diff --git a/ironicclient/osc/v1/baremetal_node.py b/ironicclient/osc/v1/baremetal_node.py index fbc2bac30..43eb280b6 100755 --- a/ironicclient/osc/v1/baremetal_node.py +++ b/ironicclient/osc/v1/baremetal_node.py @@ -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='', - 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='', + 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='', - 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='', + help=_("The identifier of a predefined runbook to use for " + "servicing.")) return parser diff --git a/ironicclient/osc/v1/baremetal_runbook.py b/ironicclient/osc/v1/baremetal_runbook.py new file mode 100644 index 000000000..b3e1f5841 --- /dev/null +++ b/ironicclient/osc/v1/baremetal_runbook.py @@ -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='', + required=True, + help=_('Unique name for this runbook. Must be a valid ' + 'trait name') + ) + parser.add_argument( + '--uuid', + dest='uuid', + metavar='', + help=_('UUID of the runbook.')) + parser.add_argument( + '--public', + metavar='', + help=_('Whether the runbook will be private or public.') + ) + parser.add_argument( + '--owner', + metavar='', + help=_('Owner of the runbook.') + ) + parser.add_argument( + '--extra', + metavar="", + action='append', + help=_("Record arbitrary key/value metadata. " + "Can be specified multiple times.")) + parser.add_argument( + '--steps', + metavar="", + 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="", + help=_("Name or UUID of the runbook.") + ) + parser.add_argument( + '--fields', + nargs='+', + dest='fields', + metavar='', + 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='', + help=_("Name or UUID of the runbook") + ) + parser.add_argument( + '--name', + metavar='', + help=_('Set unique name of the runbook. Must be a valid ' + 'trait name.') + ) + parser.add_argument( + '--public', + metavar='', + help=_('Make a private runbook public.') + ) + parser.add_argument( + '--owner', + metavar='', + help=_('Set owner of a runbook.') + ) + parser.add_argument( + '--steps', + metavar="", + help=_RUNBOOK_STEPS_HELP + ) + parser.add_argument( + "--extra", + metavar="", + 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='', + 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="", + action='append', + help=_('Step to unset on this baremetal runbook ' + '(repeat option to unset multiple steps).'), + ) + parser.add_argument( + "--extra", + metavar="", + 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="", + 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='', + 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='', + 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="[:]", + 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='', + 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)) diff --git a/ironicclient/tests/unit/osc/v1/fakes.py b/ironicclient/tests/unit/osc/v1/fakes.py index be8bfbaf1..dbe79fb36 100644 --- a/ironicclient/tests/unit/osc/v1/fakes.py +++ b/ironicclient/tests/unit/osc/v1/fakes.py @@ -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', diff --git a/ironicclient/tests/unit/osc/v1/test_baremetal_node.py b/ironicclient/tests/unit/osc/v1/test_baremetal_node.py index 1bcf32549..1d31f9a9c 100644 --- a/ironicclient/tests/unit/osc/v1/test_baremetal_node.py +++ b/ironicclient/tests/unit/osc/v1/test_baremetal_node.py @@ -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): diff --git a/ironicclient/tests/unit/osc/v1/test_baremetal_runbook.py b/ironicclient/tests/unit/osc/v1/test_baremetal_runbook.py new file mode 100644 index 000000000..3dfcfae0c --- /dev/null +++ b/ironicclient/tests/unit/osc/v1/test_baremetal_runbook.py @@ -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) diff --git a/ironicclient/tests/unit/v1/test_runbook.py b/ironicclient/tests/unit/v1/test_runbook.py new file mode 100644 index 000000000..63e20bf4f --- /dev/null +++ b/ironicclient/tests/unit/v1/test_runbook.py @@ -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]} + ), + }, +} + +fake_responses_sorting = { + '/v1/runbooks/?sort_key=updated_at': + { + 'GET': ( + {}, + {"runbooks": [RUNBOOK2, RUNBOOK]} + ), + }, + '/v1/runbooks/?sort_dir=desc': + { + 'GET': ( + {}, + {"runbooks": [RUNBOOK2, RUNBOOK]} + ), + }, +} + + +class RunbookManagerTest(testtools.TestCase): + + def setUp(self): + super(RunbookManagerTest, self).setUp() + self.api = utils.FakeAPI(fake_responses) + self.mgr = ironicclient.v1.runbook.RunbookManager( + self.api) + + def test_runbooks_list(self): + runbooks = self.mgr.list() + expect = [ + ('GET', '/v1/runbooks', {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(1, len(runbooks)) + + def test_runbooks_list_detail(self): + runbooks = self.mgr.list(detail=True) + expect = [ + ('GET', '/v1/runbooks/?detail=True', {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(1, len(runbooks)) + + def test_runbook_list_fields(self): + runbooks = self.mgr.list(fields=['uuid', 'name']) + expect = [ + ('GET', '/v1/runbooks/?fields=uuid,name', {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(1, len(runbooks)) + + def test_runbook_list_detail_and_fields_fail(self): + self.assertRaises(exc.InvalidAttribute, self.mgr.list, + detail=True, fields=['uuid', 'name']) + + def test_runbooks_list_limit(self): + self.api = utils.FakeAPI(fake_responses_pagination) + self.mgr = ironicclient.v1.runbook.RunbookManager( + self.api) + runbooks = self.mgr.list(limit=1) + expect = [ + ('GET', '/v1/runbooks/?limit=1', {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertThat(runbooks, HasLength(1)) + + def test_runbooks_list_marker(self): + self.api = utils.FakeAPI(fake_responses_pagination) + self.mgr = ironicclient.v1.runbook.RunbookManager( + self.api) + runbooks = self.mgr.list(marker=RUNBOOK['uuid']) + expect = [ + ('GET', + '/v1/runbooks/?marker=%s' % RUNBOOK['uuid'], {}, + None), + ] + self.assertEqual(expect, self.api.calls) + self.assertThat(runbooks, HasLength(1)) + + def test_runbooks_list_pagination_no_limit(self): + self.api = utils.FakeAPI(fake_responses_pagination) + self.mgr = ironicclient.v1.runbook.RunbookManager( + self.api) + runbooks = self.mgr.list(limit=0) + expect = [ + ('GET', '/v1/runbooks', {}, None), + ('GET', '/v1/runbooks/?limit=1', {}, None) + ] + self.assertEqual(expect, self.api.calls) + self.assertThat(runbooks, HasLength(2)) + + def test_runbooks_list_sort_key(self): + self.api = utils.FakeAPI(fake_responses_sorting) + self.mgr = ironicclient.v1.runbook.RunbookManager( + self.api) + runbooks = self.mgr.list(sort_key='updated_at') + expect = [ + ('GET', '/v1/runbooks/?sort_key=updated_at', {}, None) + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(2, len(runbooks)) + + def test_runbooks_list_sort_dir(self): + self.api = utils.FakeAPI(fake_responses_sorting) + self.mgr = ironicclient.v1.runbook.RunbookManager( + self.api) + runbooks = self.mgr.list(sort_dir='desc') + expect = [ + ('GET', '/v1/runbooks/?sort_dir=desc', {}, None) + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(2, len(runbooks)) + + def test_runbooks_show(self): + runbook = self.mgr.get(RUNBOOK['uuid']) + expect = [ + ('GET', '/v1/runbooks/%s' % RUNBOOK['uuid'], {}, + None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(RUNBOOK['uuid'], runbook.uuid) + self.assertEqual(RUNBOOK['name'], runbook.name) + self.assertEqual(RUNBOOK['steps'], runbook.steps) + self.assertEqual(RUNBOOK['extra'], runbook.extra) + + def test_runbook_show_fields(self): + runbook = self.mgr.get(RUNBOOK['uuid'], + fields=['uuid', 'name']) + expect = [ + ('GET', '/v1/runbooks/%s?fields=uuid,name' % + RUNBOOK['uuid'], {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(RUNBOOK['uuid'], runbook.uuid) + self.assertEqual(RUNBOOK['name'], runbook.name) + + def test_create(self): + runbook = self.mgr.create(**CREATE_RUNBOOK) + expect = [ + ('POST', '/v1/runbooks', {}, CREATE_RUNBOOK), + ] + self.assertEqual(expect, self.api.calls) + self.assertTrue(runbook) + + def test_create_with_uuid(self): + runbook = self.mgr.create(**CREATE_RUNBOOK_WITH_UUID) + expect = [ + ('POST', '/v1/runbooks', {}, + CREATE_RUNBOOK_WITH_UUID), + ] + self.assertEqual(expect, self.api.calls) + self.assertTrue(runbook) + + def test_delete(self): + runbook = self.mgr.delete( + runbook_id=RUNBOOK['uuid']) + expect = [ + ('DELETE', '/v1/runbooks/%s' % RUNBOOK['uuid'], {}, + None), + ] + self.assertEqual(expect, self.api.calls) + self.assertIsNone(runbook) + + def test_update(self): + patch = {'op': 'replace', + 'value': NEW_NAME, + 'path': '/name'} + runbook = self.mgr.update( + runbook_id=RUNBOOK['uuid'], patch=patch) + expect = [ + ('PATCH', '/v1/runbooks/%s' % RUNBOOK['uuid'], + {}, patch), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(NEW_NAME, runbook.name) diff --git a/ironicclient/v1/client.py b/ironicclient/v1/client.py index d5e4cfe6a..9af8905de 100644 --- a/ironicclient/v1/client.py +++ b/ironicclient/v1/client.py @@ -27,6 +27,7 @@ from ironicclient.v1 import events from ironicclient.v1 import node from ironicclient.v1 import port from ironicclient.v1 import portgroup +from ironicclient.v1 import runbook from ironicclient.v1 import volume_connector from ironicclient.v1 import volume_target @@ -98,6 +99,7 @@ class Client(object): self.volume_target = volume_target.VolumeTargetManager( self.http_client) self.driver = driver.DriverManager(self.http_client) + self.runbook = runbook.RunbookManager(self.http_client) self.portgroup = portgroup.PortgroupManager(self.http_client) self.conductor = conductor.ConductorManager(self.http_client) self.events = events.EventManager(self.http_client) diff --git a/ironicclient/v1/node.py b/ironicclient/v1/node.py index 0143751b2..c165b9670 100644 --- a/ironicclient/v1/node.py +++ b/ironicclient/v1/node.py @@ -729,7 +729,7 @@ class NodeManager(base.CreateManager): self, node_uuid, state, configdrive=None, cleansteps=None, rescue_password=None, os_ironic_api_version=None, global_request_id=None, deploysteps=None, - servicesteps=None): + servicesteps=None, runbook=None): """Set the provision state for the node. :param node_uuid: The UUID or name of the node. @@ -767,6 +767,8 @@ class NodeManager(base.CreateManager): dictionaries; each dictonary should have keys 'interface', 'step', and optional key 'args' when setting an 'active' nodes to 'service'. + :param runbook: The identifier of a predefined runbook to use for + provisioning. :raises: InvalidAttribute if there was an error with the clean steps or deploy steps :returns: The status of the request @@ -807,6 +809,9 @@ class NodeManager(base.CreateManager): if servicesteps: body['service_steps'] = servicesteps + if runbook: + body['runbook'] = runbook + return self.update(path, body, http_method='PUT', os_ironic_api_version=os_ironic_api_version, global_request_id=global_request_id) diff --git a/ironicclient/v1/resource_fields.py b/ironicclient/v1/resource_fields.py index 908ec1d5e..5d278bec1 100644 --- a/ironicclient/v1/resource_fields.py +++ b/ironicclient/v1/resource_fields.py @@ -158,6 +158,7 @@ class Resource(object): 'children': 'Child Nodes', 'firmware_interface': 'Firmware Interface', 'port_name': 'Port Name', + 'public': 'Public' } def __init__(self, field_ids, sort_excluded=None, override_labels=None): @@ -606,6 +607,25 @@ DEPLOY_TEMPLATE_RESOURCE = Resource( ], ) +# Runbooks +RUNBOOK_DETAILED_RESOURCE = Resource( + ['uuid', + 'name', + 'owner', + 'public', + 'steps', + 'extra', + 'created_at', + 'updated_at' + ], + sort_excluded=['extra', 'steps'] +) + +RUNBOOK_RESOURCE = Resource( + ['uuid', + 'name', + ], +) NODE_HISTORY_RESOURCE = Resource( ['uuid', diff --git a/ironicclient/v1/runbook.py b/ironicclient/v1/runbook.py new file mode 100644 index 000000000..4d058a8d0 --- /dev/null +++ b/ironicclient/v1/runbook.py @@ -0,0 +1,106 @@ +# 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 ironicclient.common import base +from ironicclient.common.i18n import _ +from ironicclient.common import utils +from ironicclient import exc + + +class Runbook(base.Resource): + def __repr__(self): + return "" % self._info + + +class RunbookManager(base.CreateManager): + resource_class = Runbook + _creation_attributes = ['extra', 'name', 'owner', 'public', 'steps', + 'uuid'] + _resource_name = 'runbooks' + + def list(self, limit=None, marker=None, sort_key=None, sort_dir=None, + detail=False, fields=None, os_ironic_api_version=None, + global_request_id=None, project=None, public=None): + """Retrieve a list of runbooks. + + :param marker: Optional, the UUID of a deploy template, eg the last + template from a previous result set. Return the next + result set. + :param limit: The maximum number of results to return per + request, if: + + 1) limit > 0, the maximum number of runbooks to return. + 2) limit == 0, return the entire list of runbooks. + 3) limit param is NOT specified (None), the number of items + returned respect the maximum imposed by the Ironic API + (see Ironic's api.max_limit option). + + :param sort_key: Optional, field used for sorting. + + :param sort_dir: Optional, direction of sorting, either 'asc' (the + default) or 'desc'. + + :param detail: Optional, boolean whether to return detailed information + about runbooks. + + :param fields: Optional, a list with a specified set of fields + of the resource to be returned. Can not be used + when 'detail' is set. + + :param os_ironic_api_version: String version (e.g. "1.35") to use for + the request. If not specified, the client's default is used. + + :param global_request_id: String containing global request ID header + value (in form "req-") to use for the request. + + :returns: A list of runbooks. + + """ + if limit is not None: + limit = int(limit) + + if detail and fields: + raise exc.InvalidAttribute(_("Can't fetch a subset of fields " + "with 'detail' set")) + + filters = utils.common_filters(marker, limit, sort_key, sort_dir, + fields, detail=detail, public=public, + project=project) + path = '' + if filters: + path += '?' + '&'.join(filters) + header_values = {"os_ironic_api_version": os_ironic_api_version, + "global_request_id": global_request_id} + if limit is None: + return self._list(self._path(path), "runbooks", + **header_values) + else: + return self._list_pagination(self._path(path), "runbooks", + limit=limit, **header_values) + + def get(self, runbook_id, fields=None, os_ironic_api_version=None, + global_request_id=None): + return self._get(resource_id=runbook_id, fields=fields, + os_ironic_api_version=os_ironic_api_version, + global_request_id=global_request_id) + + def delete(self, runbook_id, os_ironic_api_version=None, + global_request_id=None): + return self._delete(resource_id=runbook_id, + os_ironic_api_version=os_ironic_api_version, + global_request_id=global_request_id) + + def update(self, runbook_id, patch, os_ironic_api_version=None, + global_request_id=None): + return self._update(resource_id=runbook_id, patch=patch, + os_ironic_api_version=os_ironic_api_version, + global_request_id=global_request_id) diff --git a/setup.cfg b/setup.cfg index 34e69c2f6..67241c89b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -132,6 +132,12 @@ openstack.baremetal.v1 = baremetal_volume_target_unset = ironicclient.osc.v1.baremetal_volume_target:UnsetBaremetalVolumeTarget baremetal_conductor_list = ironicclient.osc.v1.baremetal_conductor:ListBaremetalConductor baremetal_conductor_show = ironicclient.osc.v1.baremetal_conductor:ShowBaremetalConductor + baremetal_runbook_create = ironicclient.osc.v1.baremetal_runbook:CreateBaremetalRunbook + baremetal_runbook_delete = ironicclient.osc.v1.baremetal_runbook:DeleteBaremetalRunbook + baremetal_runbook_list = ironicclient.osc.v1.baremetal_runbook:ListBaremetalRunbook + baremetal_runbook_set = ironicclient.osc.v1.baremetal_runbook:SetBaremetalRunbook + baremetal_runbook_unset = ironicclient.osc.v1.baremetal_runbook:UnsetBaremetalRunbook + baremetal_runbook_show = ironicclient.osc.v1.baremetal_runbook:ShowBaremetalRunbook [extras] cli =