From 01c02f8f7b51055f9132ce8dbdb35494864b54eb Mon Sep 17 00:00:00 2001 From: liusheng Date: Wed, 9 Aug 2017 14:37:38 +0800 Subject: [PATCH] Add node aggregate commands support Partially Implements: bp node-aggregate Change-Id: I37a3da4e4f35aba5efda66a62777e70476b07676 --- moganclient/osc/v1/aggregate.py | 228 ++++++++++++++++++ moganclient/tests/unit/fakes.py | 73 ++++++ .../tests/unit/osc/v1/test_aggregate.py | 224 +++++++++++++++++ moganclient/v1/aggregate.py | 47 ++++ moganclient/v1/client.py | 2 + setup.cfg | 7 +- 6 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 moganclient/osc/v1/aggregate.py create mode 100644 moganclient/tests/unit/osc/v1/test_aggregate.py create mode 100644 moganclient/v1/aggregate.py diff --git a/moganclient/osc/v1/aggregate.py b/moganclient/osc/v1/aggregate.py new file mode 100644 index 0000000..d315de9 --- /dev/null +++ b/moganclient/osc/v1/aggregate.py @@ -0,0 +1,228 @@ +# Copyright 2017 Huawei, Inc. 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. +# + +"""Mogan v1 Baremetal node aggregate action implementations""" + +import copy +import logging + +from osc_lib.cli import parseractions +from osc_lib.command import command +from osc_lib import exceptions +from osc_lib import utils + +from moganclient.common.i18n import _ + +LOG = logging.getLogger(__name__) + + +class CreateAggregate(command.ShowOne): + """Create a node aggregate""" + + def get_parser(self, prog_name): + parser = super(CreateAggregate, self).get_parser(prog_name) + parser.add_argument( + "name", + metavar="", + help=_("Name of baremetal node aggregate") + ) + parser.add_argument( + "--property", + metavar="", + action=parseractions.KeyValueAction, + help=_("Property to add to this node aggregate " + "(repeat option to set multiple properties)") + ) + return parser + + def take_action(self, parsed_args): + bc_client = self.app.client_manager.baremetal_compute + data = bc_client.aggregate.create(parsed_args.name, + parsed_args.property) + # Special mapping for columns to make the output easier to read: + # 'metadata' --> 'properties' + data._info.update( + { + 'properties': utils.format_dict(data._info.pop('metadata')), + }, + ) + info = copy.copy(data._info) + return zip(*sorted(info.items())) + + +class DeleteAggregate(command.Command): + """Delete existing baremetal node aggregate(s)""" + + def get_parser(self, prog_name): + parser = super(DeleteAggregate, self).get_parser(prog_name) + parser.add_argument( + 'aggregate', + metavar='', + nargs='+', + help=_("Aggregate(s) to delete (name or UUID)") + ) + return parser + + def take_action(self, parsed_args): + bc_client = self.app.client_manager.baremetal_compute + result = 0 + for one_aggregate in parsed_args.aggregate: + try: + data = utils.find_resource( + bc_client.aggregate, one_aggregate) + bc_client.aggregate.delete(data.uuid) + except Exception as e: + result += 1 + LOG.error(_("Failed to delete node aggregate with name or " + "UUID '%(aggregate)s': %(e)s") % + {'aggregate': one_aggregate, 'e': e}) + + if result > 0: + total = len(parsed_args.aggregate) + msg = (_("%(result)s of %(total)s node aggregates failed " + "to delete.") % {'result': result, 'total': total}) + raise exceptions.CommandError(msg) + + +class ListAggregate(command.Lister): + """List all baremetal node aggregates""" + + def take_action(self, parsed_args): + bc_client = self.app.client_manager.baremetal_compute + + data = bc_client.aggregate.list() + + column_headers = ( + "UUID", + "Name", + "Properties", + ) + columns = ( + "UUID", + "Name", + "Metadata", + ) + formatters = {'Metadata': utils.format_dict} + return (column_headers, + (utils.get_item_properties( + s, columns, formatters=formatters + ) for s in data)) + + +class ShowAggregate(command.ShowOne): + """Display baremetal node aggregate details""" + + def get_parser(self, prog_name): + parser = super(ShowAggregate, self).get_parser(prog_name) + parser.add_argument( + 'aggregate', + metavar='', + help=_("Aggregate to display (name or UUID)") + ) + return parser + + def take_action(self, parsed_args): + bc_client = self.app.client_manager.baremetal_compute + data = utils.find_resource( + bc_client.aggregate, + parsed_args.aggregate, + ) + # Special mapping for columns to make the output easier to read: + # 'metadata' --> 'properties' + data._info.update( + { + 'properties': utils.format_dict(data._info.pop('metadata')), + }, + ) + + info = {} + info.update(data._info) + return zip(*sorted(info.items())) + + +class SetAggregate(command.Command): + """Set properties for a baremetal node aggregate""" + + def get_parser(self, prog_name): + parser = super(SetAggregate, self).get_parser(prog_name) + parser.add_argument( + 'aggregate', + metavar='', + help=_("Aggregate(s) to delete (name or UUID)") + ) + parser.add_argument( + '--name', + metavar='', + help=_('Set a new name to a node aggregate (admin only)') + ) + parser.add_argument( + "--property", + metavar="", + action=parseractions.KeyValueAction, + help=_("Property to set on this node aggregate " + "(repeat option to set multiple properties)") + ) + return parser + + def take_action(self, parsed_args): + bc_client = self.app.client_manager.baremetal_compute + aggregate = utils.find_resource( + bc_client.aggregate, + parsed_args.aggregate, + ) + updates = [] + if parsed_args.name: + updates.append({"op": "replace", + "path": "/name", + "value": parsed_args.name}) + for key, value in (parsed_args.property or {}).items(): + updates.append({"op": "add", + "path": "/metadata/%s" % key, + "value": value}) + if updates: + bc_client.aggregate.update(aggregate, updates) + + +class UnsetAggregate(command.Command): + """Unset properties for a baremetal node aggregate""" + + def get_parser(self, prog_name): + parser = super(UnsetAggregate, self).get_parser(prog_name) + parser.add_argument( + 'aggregate', + metavar='', + help=_("Aggregate(s) to delete (name or UUID)") + ) + parser.add_argument( + "--property", + metavar="", + action='append', + help=_("Property to remove from this node aggregate " + "(repeat option to remove multiple properties)") + ) + return parser + + def take_action(self, parsed_args): + bc_client = self.app.client_manager.baremetal_compute + aggregate = utils.find_resource( + bc_client.aggregate, + parsed_args.aggregate, + ) + updates = [] + for key in parsed_args.property or []: + updates.append({"op": "remove", + "path": "/metadata/%s" % key}) + if updates: + bc_client.aggregate.update(aggregate, updates) diff --git a/moganclient/tests/unit/fakes.py b/moganclient/tests/unit/fakes.py index b9b3edd..2f2cb45 100644 --- a/moganclient/tests/unit/fakes.py +++ b/moganclient/tests/unit/fakes.py @@ -21,6 +21,7 @@ from oslo_utils import uuidutils from requests import Response from moganclient.common import base +from moganclient.v1 import aggregate from moganclient.v1 import availability_zone from moganclient.v1 import flavor from moganclient.v1 import node @@ -67,6 +68,7 @@ class FakeBaremetalComputeV1Client(object): self.availability_zone = availability_zone.AvailabilityZoneManager( self.fake_http_client) self.node = node.NodeManager(self.fake_http_client) + self.aggregate = aggregate.AggregateManager(self.fake_http_client) class FakeHTTPClient(object): @@ -323,3 +325,74 @@ class FakeServer(object): if servers is None: servers = FakeServer.create_servers(count) return mock.Mock(side_effect=servers) + + +class FakeAggregate(object): + """Fake one baremetal node aggregate.""" + + @staticmethod + def create_one_aggregate(attrs=None): + """Create a fake baremetal aggregate. + + :param Dictionary attrs: + A dictionary with all attributes + :return: + A FakeResource object, with uuid and other attributes + """ + attrs = attrs or {} + + # Set default attribute + agg_info = { + "created_at": "2016-09-27T02:37:21.966342+00:00", + "metadata": {"key1": "value1"}, + "links": [], + "name": "agg-name-" + uuidutils.generate_uuid(dashed=False), + "updated_at": None, + "uuid": "agg-id-" + uuidutils.generate_uuid(dashed=False), + } + + # Overwrite default attributes. + agg_info.update(attrs) + + agg = FakeResource( + manager=None, + info=copy.deepcopy(agg_info), + loaded=True) + return agg + + @staticmethod + def create_aggregates(attrs=None, count=2): + """Create multiple fake baremetal node aggregates. + + :param Dictionary attrs: + A dictionary with all attributes + :param int count: + The number of aggregates to fake + :return: + A list of FakeResource objects faking the aggregates + """ + aggs = [] + for i in range(0, count): + aggs.append( + FakeAggregate.create_one_aggregate(attrs)) + + return aggs + + @staticmethod + def get_aggregates(aggregates=None, count=2): + """Get an iterable Mock object with a list of faked aggregates. + + If aggregates list is provided, then initialize the Mock object + with the list. Otherwise create one. + + :param List aggregates: + A list of FakeResource objects faking aggregates + :param int count: + The number of aggregates to fake + :return: + An iterable Mock object with side_effect set to a list of faked + baremetal aggregates + """ + if aggregates is None: + aggregates = FakeAggregate.create_aggregates(count) + return mock.Mock(side_effect=aggregates) diff --git a/moganclient/tests/unit/osc/v1/test_aggregate.py b/moganclient/tests/unit/osc/v1/test_aggregate.py new file mode 100644 index 0000000..945f4f8 --- /dev/null +++ b/moganclient/tests/unit/osc/v1/test_aggregate.py @@ -0,0 +1,224 @@ +# Copyright 2017 Huawei, Inc. 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 mock + +from osc_lib import utils + +from moganclient.common import base +from moganclient.osc.v1 import aggregate +from moganclient.tests.unit import base as test_base +from moganclient.tests.unit import fakes +from moganclient.v1 import aggregate as aggregate_mgr + + +class TestAggregateBase(test_base.TestBaremetalComputeV1): + def setUp(self): + super(TestAggregateBase, self).setUp() + self.fake_agg = fakes.FakeAggregate.create_one_aggregate() + + self.columns = ( + 'created_at', + 'links', + 'name', + 'properties', + 'updated_at', + 'uuid', + ) + + self.data = ( + self.fake_agg.created_at, + self.fake_agg.links, + self.fake_agg.name, + utils.format_dict(self.fake_agg.metadata), + self.fake_agg.updated_at, + self.fake_agg.uuid, + ) + + +@mock.patch.object(aggregate_mgr.AggregateManager, '_create') +class TestAggregateCreate(TestAggregateBase): + def setUp(self): + super(TestAggregateCreate, self).setUp() + self.cmd = aggregate.CreateAggregate(self.app, None) + + def test_aggregate_create(self, mock_create): + arglist = [ + 'test_agg', + '--property', 'k1=v1' + ] + verifylist = [ + ('name', 'test_agg'), + ('property', {'k1': 'v1'}), + ] + mock_create.return_value = self.fake_agg + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + columns, data = self.cmd.take_action(parsed_args) + mock_create.assert_called_once_with('/aggregates', + data={ + 'name': 'test_agg', + 'metadata': {'k1': 'v1'}, + }) + self.assertEqual(self.columns, columns) + self.assertEqual(self.data, data) + + +@mock.patch.object(utils, 'find_resource') +@mock.patch.object(aggregate_mgr.AggregateManager, '_delete') +class TestAggregateDelete(TestAggregateBase): + def setUp(self): + super(TestAggregateDelete, self).setUp() + self.cmd = aggregate.DeleteAggregate(self.app, None) + + def test_aggregate_delete(self, mock_delete, mock_find): + arglist = [ + 'test_agg1', + ] + verifylist = [ + ('aggregate', ['test_agg1']), + ] + mock_find.return_value = self.fake_agg + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + result = self.cmd.take_action(parsed_args) + expected_url = '/aggregates/%s' % base.getid(self.fake_agg) + mock_delete.assert_called_once_with(expected_url) + self.assertIsNone(result) + + def test_aggregate_multiple_delete(self, mock_delete, mock_find): + arglist = [ + 'agg1', + 'agg2', + 'agg3' + ] + verifylist = [ + ('aggregate', ['agg1', 'agg2', 'agg3']), + ] + mock_find.return_value = self.fake_agg + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + result = self.cmd.take_action(parsed_args) + expected_url = '/aggregates/%s' % base.getid(self.fake_agg) + expected_call = [mock.call(expected_url), mock.call(expected_url), + mock.call(expected_url)] + mock_delete.assert_has_calls(expected_call) + self.assertIsNone(result) + + +@mock.patch.object(aggregate_mgr.AggregateManager, '_list') +class TestAggregateList(TestAggregateBase): + def setUp(self): + super(TestAggregateList, self).setUp() + self.list_columns = ( + "UUID", + "Name", + "Properties" + ) + + self.list_data = ( + (self.fake_agg.uuid, + self.fake_agg.name, + utils.format_dict(self.fake_agg.metadata), + ),) + + self.cmd = aggregate.ListAggregate(self.app, None) + + def test_aggregate_list(self, mock_list): + arglist = [] + verifylist = [] + mock_list.return_value = [self.fake_agg] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + columns, data = self.cmd.take_action(parsed_args) + mock_list.assert_called_once_with('/aggregates', + response_key='aggregates') + self.assertEqual(self.list_columns, columns) + self.assertEqual(self.list_data, tuple(data)) + + +@mock.patch.object(aggregate_mgr.AggregateManager, '_get') +class TestAggregateShow(TestAggregateBase): + def setUp(self): + super(TestAggregateShow, self).setUp() + self.cmd = aggregate.ShowAggregate(self.app, None) + + def test_agregate_show(self, mock_get): + arglist = [ + 'agg1', + ] + verifylist = [ + ('aggregate', 'agg1'), + ] + mock_get.return_value = self.fake_agg + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + columns, data = self.cmd.take_action(parsed_args) + expected_url = '/aggregates/%s' % parsed_args.aggregate + mock_get.assert_called_once_with(expected_url) + self.assertEqual(self.columns, columns) + self.assertEqual(self.data, data) + + +@mock.patch.object(utils, 'find_resource') +@mock.patch.object(aggregate_mgr.AggregateManager, '_update') +class TestAggregateSet(TestAggregateBase): + def setUp(self): + super(TestAggregateSet, self).setUp() + self.cmd = aggregate.SetAggregate(self.app, None) + + def test_aggregate_update(self, mock_update, mock_find): + mock_find.return_value = self.fake_agg + arglist = [ + '--name', 'test_agg', + '--property', 'k1=v1', + self.fake_agg.uuid, + ] + verifylist = [ + ('aggregate', self.fake_agg.uuid), + ('name', 'test_agg'), + ('property', {'k1': 'v1'}), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + self.cmd.take_action(parsed_args) + expected_url = '/aggregates/%s' % base.getid(self.fake_agg) + expected_args = [ + {'path': '/name', 'value': 'test_agg', 'op': 'replace'}, + {'path': '/metadata/k1', 'value': 'v1', 'op': 'add'}, + ] + mock_update.assert_called_once_with(expected_url, + data=expected_args) + + +@mock.patch.object(utils, 'find_resource') +@mock.patch.object(aggregate_mgr.AggregateManager, '_update') +class TestAggregateUnset(TestAggregateBase): + def setUp(self): + super(TestAggregateUnset, self).setUp() + self.cmd = aggregate.UnsetAggregate(self.app, None) + + def test_aggregate_update(self, mock_update, mock_find): + mock_find.return_value = self.fake_agg + arglist = [ + '--property', 'key1', + self.fake_agg.uuid, + ] + verifylist = [ + ('aggregate', self.fake_agg.uuid), + ('property', ['key1']), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + self.cmd.take_action(parsed_args) + expected_url = '/aggregates/%s' % base.getid(self.fake_agg) + expected_args = [ + {'path': '/metadata/key1', 'op': 'remove'} + ] + mock_update.assert_called_once_with(expected_url, + data=expected_args) diff --git a/moganclient/v1/aggregate.py b/moganclient/v1/aggregate.py new file mode 100644 index 0000000..db90d68 --- /dev/null +++ b/moganclient/v1/aggregate.py @@ -0,0 +1,47 @@ +# Copyright 2017 Huawei, Inc. 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. +# + +from moganclient.common import base + + +class Aggregate(base.Resource): + pass + + +class AggregateManager(base.ManagerWithFind): + resource_class = Aggregate + + def create(self, name, metadata=None): + url = '/aggregates' + data = {'name': name} + if metadata: + data['metadata'] = metadata + return self._create(url, data=data) + + def delete(self, aggregate): + url = '/aggregates/%s' % base.getid(aggregate) + return self._delete(url) + + def get(self, aggregate): + url = '/aggregates/%s' % base.getid(aggregate) + return self._get(url) + + def list(self): + url = '/aggregates' + return self._list(url, response_key='aggregates') + + def update(self, aggregate, updates): + url = '/aggregates/%s' % base.getid(aggregate) + return self._update(url, data=updates) diff --git a/moganclient/v1/client.py b/moganclient/v1/client.py index 207e80c..dc96314 100644 --- a/moganclient/v1/client.py +++ b/moganclient/v1/client.py @@ -14,6 +14,7 @@ # from moganclient.common import http +from moganclient.v1 import aggregate from moganclient.v1 import availability_zone from moganclient.v1 import flavor from moganclient.v1 import keypair @@ -35,3 +36,4 @@ class Client(object): self.http_client) self.keypair = keypair.KeyPairManager(self.http_client) self.node = node.NodeManager(self.http_client) + self.aggregate = aggregate.AggregateManager(self.http_client) diff --git a/setup.cfg b/setup.cfg index 34c71f7..bab6ca2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -59,7 +59,12 @@ openstack.baremetal_compute.v1 = # TODO(liusheng): may change the "baremetal" to another word to avoid # conflict with Ironic baremetal_compute_node_list = moganclient.osc.v1.node:ListNode - + baremetal_aggregate_create = moganclient.osc.v1.aggregate:CreateAggregate + baremetal_aggregate_show = moganclient.osc.v1.aggregate:ShowAggregate + baremetal_aggregate_list = moganclient.osc.v1.aggregate:ListAggregate + baremetal_aggregate_delete = moganclient.osc.v1.aggregate:DeleteAggregate + baremetal_aggregate_set = moganclient.osc.v1.aggregate:SetAggregate + baremetal_aggregate_unset = moganclient.osc.v1.aggregate:UnsetAggregate [build_sphinx] source-dir = doc/source