diff --git a/ironicclient/common/http.py b/ironicclient/common/http.py index 659981692..0558f53ff 100644 --- a/ironicclient/common/http.py +++ b/ironicclient/common/http.py @@ -43,7 +43,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 = 48 +LAST_KNOWN_API_VERSION = 49 LATEST_VERSION = '1.{}'.format(LAST_KNOWN_API_VERSION) LOG = logging.getLogger(__name__) diff --git a/ironicclient/osc/v1/baremetal_conductor.py b/ironicclient/osc/v1/baremetal_conductor.py new file mode 100755 index 000000000..627fd6227 --- /dev/null +++ b/ironicclient/osc/v1/baremetal_conductor.py @@ -0,0 +1,145 @@ +# +# Copyright 2015 Red Hat, Inc. +# +# 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 logging + +from osc_lib.command import command +from osc_lib import utils as oscutils + +from ironicclient.common.i18n import _ +from ironicclient import exc +from ironicclient.v1 import resource_fields as res_fields + + +class ListBaremetalConductor(command.Lister): + """List baremetal conductors""" + + log = logging.getLogger(__name__ + ".ListBaremetalNode") + + def get_parser(self, prog_name): + parser = super(ListBaremetalConductor, self).get_parser(prog_name) + parser.add_argument( + '--limit', + metavar='', + type=int, + help=_('Maximum number of conductors 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=_('Hostname of the conductor (for example, of the last ' + 'conductor in the list from a previous request). Returns ' + 'the list of conductors after this conductor.') + ) + parser.add_argument( + '--sort', + metavar="[:]", + help=_('Sort output by specified conductor 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(required=False) + display_group.add_argument( + '--long', + default=False, + help=_("Show detailed information about the conductors."), + action='store_true') + display_group.add_argument( + '--fields', + nargs='+', + dest='fields', + metavar='', + action='append', + default=[], + choices=res_fields.CONDUCTOR_DETAILED_RESOURCE.fields, + help=_("One or more conductor 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.CONDUCTOR_RESOURCE.fields + labels = res_fields.CONDUCTOR_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.long: + params['detail'] = parsed_args.long + columns = res_fields.CONDUCTOR_DETAILED_RESOURCE.fields + labels = res_fields.CONDUCTOR_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.conductor.list(**params) + + data = oscutils.sort_items(data, parsed_args.sort) + + return (labels, + (oscutils.get_item_properties(s, columns, formatters={ + 'Properties': oscutils.format_dict},) for s in data)) + + +class ShowBaremetalConductor(command.ShowOne): + """Show baremetal conductor details""" + + log = logging.getLogger(__name__ + ".ShowBaremetalConductor") + + def get_parser(self, prog_name): + parser = super(ShowBaremetalConductor, self).get_parser(prog_name) + parser.add_argument( + "conductor", + metavar="", + help=_("Hostname of the conductor")) + parser.add_argument( + '--fields', + nargs='+', + dest='fields', + metavar='', + action='append', + choices=res_fields.CONDUCTOR_DETAILED_RESOURCE.fields, + default=[], + help=_("One or more conductor 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 + conductor = baremetal_client.conductor.get( + parsed_args.conductor, fields=fields)._info + conductor.pop("links", None) + + return self.dict2columns(conductor) diff --git a/ironicclient/osc/v1/baremetal_node.py b/ironicclient/osc/v1/baremetal_node.py index e1fafcb19..f1d6cec0f 100755 --- a/ironicclient/osc/v1/baremetal_node.py +++ b/ironicclient/osc/v1/baremetal_node.py @@ -608,6 +608,10 @@ class ListBaremetalNode(command.Lister): metavar='', help=_("Limit list to nodes with conductor group ")) + parser.add_argument( + '--conductor', + metavar='', + help=_("Limit list to nodes with conductor ")) parser.add_argument( '--chassis', dest='chassis', @@ -654,7 +658,7 @@ class ListBaremetalNode(command.Lister): if getattr(parsed_args, field) is not None: params[field] = getattr(parsed_args, field) for field in ['provision_state', 'driver', 'resource_class', - 'chassis']: + 'chassis', 'conductor']: if getattr(parsed_args, field): params[field] = getattr(parsed_args, field) if parsed_args.long: diff --git a/ironicclient/tests/functional/osc/v1/base.py b/ironicclient/tests/functional/osc/v1/base.py index 2ab69cb93..365a349d9 100644 --- a/ironicclient/tests/functional/osc/v1/base.py +++ b/ironicclient/tests/functional/osc/v1/base.py @@ -323,3 +323,28 @@ class TestCase(base.FunctionalTestBase): output = self.openstack('baremetal driver list {0} {1}' .format(opts, params)) return json.loads(output) + + def conductor_show(self, hostname, fields=None, params=''): + """Show specified baremetal conductors. + + :param String hostname: hostname of the conductor + :param List fields: List of fields to show + :param List params: Additional kwargs + :return: JSON object of driver + """ + opts = self.get_opts(fields=fields) + output = self.openstack('baremetal conductor show {0} {1} {2}' + .format(opts, hostname, params)) + return json.loads(output) + + def conductor_list(self, fields=None, params=''): + """List baremetal conductors. + + :param List fields: List of fields to show + :param String params: Additional kwargs + :return: list of JSON driver objects + """ + opts = self.get_opts(fields=fields) + output = self.openstack('baremetal conductor list {0} {1}' + .format(opts, params)) + return json.loads(output) diff --git a/ironicclient/tests/functional/osc/v1/test_baremetal_conductor_basic.py b/ironicclient/tests/functional/osc/v1/test_baremetal_conductor_basic.py new file mode 100644 index 000000000..01f4c4faf --- /dev/null +++ b/ironicclient/tests/functional/osc/v1/test_baremetal_conductor_basic.py @@ -0,0 +1,38 @@ +# 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.tests.functional.osc.v1 import base + + +class BaremetalConductorTests(base.TestCase): + """Functional tests for baremetal conductor commands.""" + + def test_list(self): + """List available conductors. + + There is at lease one conductor in the functional tests, if not, other + tests will fail too. + """ + hostnames = [c['Hostname'] for c in self.conductor_list()] + self.assertIsNotNone(hostnames) + + def test_show(self): + """Show specified conductor. + + Conductor name varies in different environment, list first, then show + one of them. + """ + conductors = self.conductor_list() + conductor = self.conductor_show(conductors[0]['Hostname']) + self.assertIn('conductor_group', conductor) + self.assertIn('alive', conductor) + self.assertIn('drivers', conductor) diff --git a/ironicclient/tests/unit/osc/v1/fakes.py b/ironicclient/tests/unit/osc/v1/fakes.py index 57b659bb6..ebf5b49a4 100644 --- a/ironicclient/tests/unit/osc/v1/fakes.py +++ b/ironicclient/tests/unit/osc/v1/fakes.py @@ -180,6 +180,17 @@ VOLUME_TARGET = { 'properties': baremetal_volume_target_properties, } +baremetal_hostname = 'compute1.localdomain' +baremetal_conductor_group = 'foo' +baremetal_alive = True +baremetal_drivers = ['fake-hardware'] +CONDUCTOR = { + 'hostname': baremetal_hostname, + 'conductor_group': baremetal_conductor_group, + 'alive': baremetal_alive, + 'drivers': baremetal_drivers, +} + class TestBaremetal(utils.TestCommand): diff --git a/ironicclient/tests/unit/osc/v1/test_baremetal_conductor.py b/ironicclient/tests/unit/osc/v1/test_baremetal_conductor.py new file mode 100644 index 000000000..e9708a970 --- /dev/null +++ b/ironicclient/tests/unit/osc/v1/test_baremetal_conductor.py @@ -0,0 +1,309 @@ +# 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 + +from osc_lib.tests import utils as oscutils + +from ironicclient.osc.v1 import baremetal_conductor +from ironicclient.tests.unit.osc.v1 import fakes as baremetal_fakes + + +class TestBaremetalConductor(baremetal_fakes.TestBaremetal): + + def setUp(self): + super(TestBaremetalConductor, self).setUp() + + # Get a shortcut to the baremetal manager mock + self.baremetal_mock = self.app.client_manager.baremetal + self.baremetal_mock.reset_mock() + + +class TestBaremetalConductorList(TestBaremetalConductor): + + def setUp(self): + super(TestBaremetalConductorList, self).setUp() + + self.baremetal_mock.conductor.list.return_value = [ + baremetal_fakes.FakeBaremetalResource( + None, + copy.deepcopy(baremetal_fakes.CONDUCTOR), + loaded=True, + ), + ] + + # Get the command object to test + self.cmd = baremetal_conductor.ListBaremetalConductor(self.app, None) + + def test_conductor_list_no_options(self): + arglist = [] + verifylist = [] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + + # Set expected values + kwargs = { + 'marker': None, + 'limit': None, + } + + self.baremetal_mock.conductor.list.assert_called_with( + **kwargs + ) + + collist = ( + "Hostname", + "Conductor Group", + "Alive", + ) + self.assertEqual(collist, columns) + datalist = (( + baremetal_fakes.baremetal_hostname, + baremetal_fakes.baremetal_conductor_group, + baremetal_fakes.baremetal_alive, + ), ) + self.assertEqual(datalist, tuple(data)) + + def test_conductor_list_long(self): + arglist = [ + '--long', + ] + verifylist = [ + ('long', True), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + + # Set expected values + kwargs = { + 'detail': True, + 'marker': None, + 'limit': None, + } + + self.baremetal_mock.conductor.list.assert_called_with( + **kwargs + ) + + collist = [ + 'Hostname', + 'Conductor Group', + 'Alive', + 'Drivers', + 'Created At', + 'Updated At', + ] + self.assertEqual(tuple(collist), columns) + + fake_values = { + 'Hostname': baremetal_fakes.baremetal_hostname, + 'Conductor Group': baremetal_fakes.baremetal_conductor_group, + 'Alive': baremetal_fakes.baremetal_alive, + 'Drivers': baremetal_fakes.baremetal_drivers, + } + values = tuple(fake_values.get(name, '') for name in collist) + self.assertEqual((values,), tuple(data)) + + def test_conductor_list_fields(self): + arglist = [ + '--fields', 'hostname', 'alive', + ] + verifylist = [ + ('fields', [['hostname', 'alive']]), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + self.cmd.take_action(parsed_args) + + # Set expected values + kwargs = { + 'marker': None, + 'limit': None, + 'detail': False, + 'fields': ('hostname', 'alive'), + } + + self.baremetal_mock.conductor.list.assert_called_with( + **kwargs + ) + + def test_conductor_list_fields_multiple(self): + arglist = [ + '--fields', 'hostname', 'alive', + '--fields', 'conductor_group', + ] + verifylist = [ + ('fields', [['hostname', 'alive'], ['conductor_group']]) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.cmd.take_action(parsed_args) + + # Set expected values + kwargs = { + 'marker': None, + 'limit': None, + 'detail': False, + 'fields': ('hostname', 'alive', 'conductor_group') + } + + self.baremetal_mock.conductor.list.assert_called_with( + **kwargs + ) + + def test_conductor_list_invalid_fields(self): + arglist = [ + '--fields', 'hostname', 'invalid' + ] + verifylist = [ + ('fields', [['hostname', 'invalid']]) + ] + + self.assertRaises(oscutils.ParserException, + self.check_parser, + self.cmd, arglist, verifylist) + + +class TestBaremetalConductorShow(TestBaremetalConductor): + def setUp(self): + super(TestBaremetalConductorShow, self).setUp() + + self.baremetal_mock.conductor.get.return_value = ( + baremetal_fakes.FakeBaremetalResource( + None, + copy.deepcopy(baremetal_fakes.CONDUCTOR), + loaded=True, + )) + + # Get the command object to test + self.cmd = baremetal_conductor.ShowBaremetalConductor(self.app, None) + + def test_conductor_show(self): + arglist = ['xxxx.xxxx'] + verifylist = [] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + + # Set expected values + args = ['xxxx.xxxx'] + + self.baremetal_mock.conductor.get.assert_called_with( + *args, fields=None + ) + + collist = ('alive', + 'conductor_group', + 'drivers', + 'hostname', + ) + self.assertEqual(collist, columns) + datalist = ( + baremetal_fakes.baremetal_alive, + baremetal_fakes.baremetal_conductor_group, + baremetal_fakes.baremetal_drivers, + baremetal_fakes.baremetal_hostname, + ) + self.assertEqual(datalist, tuple(data)) + + def test_conductor_show_no_conductor(self): + arglist = [] + verifylist = [] + + self.assertRaises(oscutils.ParserException, + self.check_parser, + self.cmd, arglist, verifylist) + + def test_conductor_show_fields(self): + arglist = [ + 'xxxxx', + '--fields', 'hostname', 'alive', + ] + verifylist = [ + ('conductor', 'xxxxx'), + ('fields', [['hostname', 'alive']]), + ] + + fake_cond = copy.deepcopy(baremetal_fakes.CONDUCTOR) + fake_cond.pop('conductor_group') + fake_cond.pop('drivers') + self.baremetal_mock.conductor.get.return_value = ( + baremetal_fakes.FakeBaremetalResource(None, fake_cond, + loaded=True)) + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + self.assertNotIn('conductor_group', columns) + + # Set expected values + args = ['xxxxx'] + fields = ['hostname', 'alive'] + + self.baremetal_mock.conductor.get.assert_called_with( + *args, fields=fields + ) + + def test_conductor_show_fields_multiple(self): + arglist = [ + 'xxxxx', + '--fields', 'hostname', 'alive', + '--fields', 'conductor_group', + ] + verifylist = [ + ('conductor', 'xxxxx'), + ('fields', [['hostname', 'alive'], ['conductor_group']]) + ] + + fake_cond = copy.deepcopy(baremetal_fakes.CONDUCTOR) + fake_cond.pop('drivers') + self.baremetal_mock.conductor.get.return_value = ( + baremetal_fakes.FakeBaremetalResource(None, fake_cond, + loaded=True)) + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + self.assertNotIn('drivers', columns) + # Set expected values + args = ['xxxxx'] + fields = ['hostname', 'alive', 'conductor_group'] + + self.baremetal_mock.conductor.get.assert_called_with( + *args, fields=fields + ) + + def test_conductor_show_invalid_fields(self): + arglist = [ + 'xxxxx', + '--fields', 'hostname', 'invalid' + ] + verifylist = [ + ('conductor', 'xxxxx'), + ('fields', [['hostname', 'invalid']]) + ] + + self.assertRaises(oscutils.ParserException, + self.check_parser, + self.cmd, arglist, verifylist) diff --git a/ironicclient/tests/unit/osc/v1/test_baremetal_node.py b/ironicclient/tests/unit/osc/v1/test_baremetal_node.py index de920057a..e6d9ed7be 100644 --- a/ironicclient/tests/unit/osc/v1/test_baremetal_node.py +++ b/ironicclient/tests/unit/osc/v1/test_baremetal_node.py @@ -610,6 +610,7 @@ class TestBaremetalList(TestBaremetal): 'Boot Interface', 'Chassis UUID', 'Clean Step', + 'Conductor', 'Conductor Group', 'Console Enabled', 'Console Interface', @@ -961,6 +962,31 @@ class TestBaremetalList(TestBaremetal): **kwargs ) + def test_baremetal_list_by_conductor(self): + conductor = 'fake-conductor' + arglist = [ + '--conductor', conductor, + ] + verifylist = [ + ('conductor', conductor), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + self.cmd.take_action(parsed_args) + + # Set expected values + kwargs = { + 'marker': None, + 'limit': None, + 'conductor': conductor + } + + self.baremetal_mock.node.list.assert_called_with( + **kwargs + ) + def test_baremetal_list_fields(self): arglist = [ '--fields', 'uuid', 'name', diff --git a/ironicclient/tests/unit/v1/test_conductor.py b/ironicclient/tests/unit/v1/test_conductor.py new file mode 100644 index 000000000..15656db72 --- /dev/null +++ b/ironicclient/tests/unit/v1/test_conductor.py @@ -0,0 +1,211 @@ +# 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 testtools +from testtools.matchers import HasLength + +from ironicclient.tests.unit import utils +from ironicclient.v1 import conductor + + +CONDUCTOR1 = {'hostname': 'compute1.localdomain', + 'conductor_group': 'alpha-team', + 'alive': True, + 'drivers': ['ipmitool', 'fake-hardware'], + } +CONDUCTOR2 = {'hostname': 'compute2.localdomain', + 'conductor_group': 'alpha-team', + 'alive': True, + 'drivers': ['ipmitool', 'fake-hardware'], + } + +fake_responses = { + '/v1/conductors': + { + 'GET': ( + {}, + {"conductors": [CONDUCTOR1, CONDUCTOR2]} + ), + }, + '/v1/conductors/?detail=True': + { + 'GET': ( + {}, + {"conductors": [CONDUCTOR1, CONDUCTOR2]} + ), + }, + '/v1/conductors/?fields=hostname,alive': + { + 'GET': ( + {}, + {"conductors": [CONDUCTOR1]} + ), + }, + '/v1/conductors/%s' % CONDUCTOR1['hostname']: + { + 'GET': ( + {}, + CONDUCTOR1, + ), + }, + '/v1/conductors/%s?fields=hostname,alive' % CONDUCTOR1['hostname']: + { + 'GET': ( + {}, + CONDUCTOR1, + ), + }, +} + +fake_responses_pagination = { + '/v1/conductors': + { + 'GET': ( + {}, + {"conductors": [CONDUCTOR1], + "next": "http://127.0.0.1:6385/v1/conductors/?limit=1"} + ), + }, + '/v1/conductors/?limit=1': + { + 'GET': ( + {}, + {"conductors": [CONDUCTOR2]} + ), + }, + '/v1/conductors/?marker=%s' % CONDUCTOR1['hostname']: + { + 'GET': ( + {}, + {"conductors": [CONDUCTOR2]} + ), + }, +} + +fake_responses_sorting = { + '/v1/conductors/?sort_key=updated_at': + { + 'GET': ( + {}, + {"conductors": [CONDUCTOR2, CONDUCTOR1]} + ), + }, + '/v1/conductors/?sort_dir=desc': + { + 'GET': ( + {}, + {"conductors": [CONDUCTOR2, CONDUCTOR1]} + ), + }, +} + + +class ConductorManagerTest(testtools.TestCase): + + def setUp(self): + super(ConductorManagerTest, self).setUp() + self.api = utils.FakeAPI(fake_responses) + self.mgr = conductor.ConductorManager(self.api) + + def test_conductor_list(self): + conductors = self.mgr.list() + expect = [ + ('GET', '/v1/conductors', {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(2, len(conductors)) + + def test_conductor_list_detail(self): + conductors = self.mgr.list(detail=True) + expect = [ + ('GET', '/v1/conductors/?detail=True', {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(2, len(conductors)) + + def test_conductor_list_limit(self): + self.api = utils.FakeAPI(fake_responses_pagination) + self.mgr = conductor.ConductorManager(self.api) + conductors = self.mgr.list(limit=1) + expect = [ + ('GET', '/v1/conductors/?limit=1', {}, None) + ] + self.assertEqual(expect, self.api.calls) + self.assertThat(conductors, HasLength(1)) + + def test_conductor_list_marker(self): + self.api = utils.FakeAPI(fake_responses_pagination) + self.mgr = conductor.ConductorManager(self.api) + conductors = self.mgr.list(marker=CONDUCTOR1['hostname']) + expect = [ + ('GET', '/v1/conductors/?marker=%s' % CONDUCTOR1['hostname'], + {}, None) + ] + self.assertEqual(expect, self.api.calls) + self.assertThat(conductors, HasLength(1)) + + def test_conductor_list_pagination_no_limit(self): + self.api = utils.FakeAPI(fake_responses_pagination) + self.mgr = conductor.ConductorManager(self.api) + conductors = self.mgr.list(limit=0) + expect = [ + ('GET', '/v1/conductors', {}, None), + ('GET', '/v1/conductors/?limit=1', {}, None) + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(2, len(conductors)) + + def test_conductor_list_sort_key(self): + self.api = utils.FakeAPI(fake_responses_sorting) + self.mgr = conductor.ConductorManager(self.api) + conductors = self.mgr.list(sort_key='updated_at') + expect = [ + ('GET', '/v1/conductors/?sort_key=updated_at', {}, None) + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(2, len(conductors)) + + def test_conductor_list_sort_dir(self): + self.api = utils.FakeAPI(fake_responses_sorting) + self.mgr = conductor.ConductorManager(self.api) + conductors = self.mgr.list(sort_dir='desc') + expect = [ + ('GET', '/v1/conductors/?sort_dir=desc', {}, None) + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(2, len(conductors)) + + def test_conductor_list_fields(self): + conductors = self.mgr.list(fields=['hostname', 'alive']) + expect = [ + ('GET', '/v1/conductors/?fields=hostname,alive', {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(1, len(conductors)) + + def test_conductor_show(self): + conductor = self.mgr.get(CONDUCTOR1['hostname']) + expect = [ + ('GET', '/v1/conductors/%s' % CONDUCTOR1['hostname'], {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(CONDUCTOR1['hostname'], conductor.hostname) + + def test_conductor_show_fields(self): + conductor = self.mgr.get(CONDUCTOR1['hostname'], + fields=['hostname', 'alive']) + expect = [ + ('GET', '/v1/conductors/%s?fields=hostname,alive' % + CONDUCTOR1['hostname'], {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(CONDUCTOR1['hostname'], conductor.hostname) diff --git a/ironicclient/tests/unit/v1/test_node.py b/ironicclient/tests/unit/v1/test_node.py index 01090c5ff..6afe1e280 100644 --- a/ironicclient/tests/unit/v1/test_node.py +++ b/ironicclient/tests/unit/v1/test_node.py @@ -215,6 +215,13 @@ fake_responses = { {"nodes": [NODE2]}, ) }, + '/v1/nodes/?conductor=fake-conductor': + { + 'GET': ( + {}, + {"nodes": [NODE2]}, + ) + }, '/v1/nodes/detail?instance_uuid=%s' % NODE2['instance_uuid']: { 'GET': ( @@ -825,6 +832,15 @@ class NodeManagerTest(testtools.TestCase): self.assertThat(nodes, HasLength(1)) self.assertEqual(NODE2['uuid'], getattr(nodes[0], 'uuid')) + def test_node_list_with_conductor(self): + nodes = self.mgr.list(conductor='fake-conductor') + expect = [ + ('GET', '/v1/nodes/?conductor=fake-conductor', {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertThat(nodes, HasLength(1)) + self.assertEqual(NODE2['uuid'], getattr(nodes[0], 'uuid')) + def test_node_list_detail(self): nodes = self.mgr.list(detail=True) expect = [ diff --git a/ironicclient/tests/unit/v1/test_node_shell.py b/ironicclient/tests/unit/v1/test_node_shell.py index 04319c2a9..6608afb86 100644 --- a/ironicclient/tests/unit/v1/test_node_shell.py +++ b/ironicclient/tests/unit/v1/test_node_shell.py @@ -37,6 +37,7 @@ class NodeShellTest(utils.BaseTestCase): 'chassis_uuid', 'clean_step', 'created_at', + 'conductor', 'conductor_group', 'console_enabled', 'deploy_step', diff --git a/ironicclient/v1/client.py b/ironicclient/v1/client.py index d7dd55381..81f73d428 100644 --- a/ironicclient/v1/client.py +++ b/ironicclient/v1/client.py @@ -21,6 +21,7 @@ from ironicclient.common.http import DEFAULT_VER from ironicclient.common.i18n import _ from ironicclient import exc from ironicclient.v1 import chassis +from ironicclient.v1 import conductor from ironicclient.v1 import driver from ironicclient.v1 import node from ironicclient.v1 import port @@ -97,6 +98,7 @@ class Client(object): self.http_client) self.driver = driver.DriverManager(self.http_client) self.portgroup = portgroup.PortgroupManager(self.http_client) + self.conductor = conductor.ConductorManager(self.http_client) @property def current_api_version(self): diff --git a/ironicclient/v1/conductor.py b/ironicclient/v1/conductor.py new file mode 100644 index 000000000..deae4c248 --- /dev/null +++ b/ironicclient/v1/conductor.py @@ -0,0 +1,79 @@ +# 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 Conductor(base.Resource): + def __repr__(self): + return "" % self._info + + +class ConductorManager(base.Manager): + resource_class = Conductor + _resource_name = 'conductors' + + def list(self, marker=None, limit=None, sort_key=None, sort_dir=None, + fields=None, detail=False): + """Retrieve a list of conductors. + + :param marker: Optional, the hostname of a conductor, eg the last + conductor 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 ports to return. + 2) limit == 0, return the entire list of ports. + 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 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 detail: Optional, boolean whether to return detailed information + about conductors. + + :returns: A list of conductors. + + """ + 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) + path = '' + if filters: + path += '?' + '&'.join(filters) + + if limit is None: + return self._list(self._path(path), "conductors") + else: + return self._list_pagination(self._path(path), "conductors", + limit=limit) + + def get(self, hostname, fields=None): + return self._get(resource_id=hostname, fields=fields) diff --git a/ironicclient/v1/node.py b/ironicclient/v1/node.py index 7c7ccc17f..129d310de 100644 --- a/ironicclient/v1/node.py +++ b/ironicclient/v1/node.py @@ -61,7 +61,7 @@ class NodeManager(base.CreateManager): detail=False, sort_key=None, sort_dir=None, fields=None, provision_state=None, driver=None, resource_class=None, chassis=None, fault=None, os_ironic_api_version=None, - conductor_group=None): + conductor_group=None, conductor=None): """Retrieve a list of nodes. :param associated: Optional. Either a Boolean or a string @@ -115,6 +115,8 @@ class NodeManager(base.CreateManager): :param conductor_group: Optional. String value to get only nodes with the given conductor group set. + :param conductor: Optional. String value to get only nodes + mapped to the given conductor. :returns: A list of nodes. @@ -144,6 +146,8 @@ class NodeManager(base.CreateManager): filters.append('chassis_uuid=%s' % chassis) if conductor_group is not None: filters.append('conductor_group=%s' % conductor_group) + if conductor is not None: + filters.append('conductor=%s' % conductor) path = '' if detail: diff --git a/ironicclient/v1/resource_fields.py b/ironicclient/v1/resource_fields.py index 570f9d5df..d8ab2857b 100644 --- a/ironicclient/v1/resource_fields.py +++ b/ironicclient/v1/resource_fields.py @@ -32,6 +32,7 @@ class Resource(object): FIELDS = { 'address': 'Address', + 'alive': 'Alive', 'async': 'Async', 'automated_clean': 'Automated Clean', 'attach': 'Response is attachment', @@ -40,6 +41,7 @@ class Resource(object): 'boot_index': 'Boot Index', 'chassis_uuid': 'Chassis UUID', 'clean_step': 'Clean Step', + 'conductor': 'Conductor', 'conductor_group': 'Conductor Group', 'console_enabled': 'Console Enabled', 'created_at': 'Created At', @@ -60,6 +62,7 @@ class Resource(object): 'driver': 'Driver', 'driver_info': 'Driver Info', 'driver_internal_info': 'Driver Internal Info', + 'drivers': 'Drivers', 'enabled_bios_interfaces': 'Enabled BIOS Interfaces', 'enabled_boot_interfaces': 'Enabled Boot Interfaces', 'enabled_console_interfaces': 'Enabled Console Interfaces', @@ -73,6 +76,7 @@ class Resource(object): 'enabled_storage_interfaces': 'Enabled Storage Interfaces', 'enabled_vendor_interfaces': 'Enabled Vendor Interfaces', 'extra': 'Extra', + 'hostname': 'Hostname', 'hosts': 'Active host(s)', 'http_methods': 'Supported HTTP methods', 'inspection_finished_at': 'Inspection Finished At', @@ -210,6 +214,7 @@ NODE_DETAILED_RESOURCE = Resource( 'boot_interface', 'chassis_uuid', 'clean_step', + 'conductor', 'conductor_group', 'console_enabled', 'console_interface', @@ -450,3 +455,24 @@ VOLUME_TARGET_RESOURCE = Resource( ], sort_excluded=['node_uuid'] ) + +# Conductors +CONDUCTOR_DETAILED_RESOURCE = Resource( + ['hostname', + 'conductor_group', + 'alive', + 'drivers', + 'created_at', + 'updated_at', + ], + sort_excluded=[ + 'alive', + 'drivers', + ]) +CONDUCTOR_RESOURCE = Resource( + ['hostname', + 'conductor_group', + 'alive', + ], + sort_excluded=['alive'] +) diff --git a/releasenotes/notes/add-conductor-cli-233249ebc9d5a5f3.yaml b/releasenotes/notes/add-conductor-cli-233249ebc9d5a5f3.yaml new file mode 100644 index 000000000..495596381 --- /dev/null +++ b/releasenotes/notes/add-conductor-cli-233249ebc9d5a5f3.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Adds the ability to list and show conductors known by the Bare Metal + service, as well as showing the ``conductor`` field on the node, + introduced in API 1.49. \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 9344ec657..17920f969 100644 --- a/setup.cfg +++ b/setup.cfg @@ -105,6 +105,8 @@ openstack.baremetal.v1 = baremetal_volume_target_set = ironicclient.osc.v1.baremetal_volume_target:SetBaremetalVolumeTarget baremetal_volume_target_show = ironicclient.osc.v1.baremetal_volume_target:ShowBaremetalVolumeTarget 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 [wheel]