diff --git a/fuelclient/commands/base.py b/fuelclient/commands/base.py index 640998a..904c090 100644 --- a/fuelclient/commands/base.py +++ b/fuelclient/commands/base.py @@ -116,9 +116,10 @@ class BaseDeleteCommand(BaseCommand): def get_parser(self, prog_name): parser = super(BaseDeleteCommand, self).get_parser(prog_name) - parser.add_argument('id', type=int, - help='Id of the {0} to ' - 'delete.'.format(self.entity_name)) + parser.add_argument( + 'id', + type=int, + help='Id of the {0} to delete.'.format(self.entity_name)) return parser @@ -127,5 +128,7 @@ class BaseDeleteCommand(BaseCommand): msg = '{ent} with id {ent_id} was deleted\n' - self.app.stdout.write(msg.format(ent=self.entity_name.capitalize(), - ent_id=parsed_args.id)) + self.app.stdout.write( + msg.format( + ent=self.entity_name.capitalize(), + ent_id=parsed_args.id)) diff --git a/fuelclient/commands/node.py b/fuelclient/commands/node.py index 3aaa66d..28aec0f 100644 --- a/fuelclient/commands/node.py +++ b/fuelclient/commands/node.py @@ -43,14 +43,19 @@ class NodeList(NodeMixIn, base.BaseListCommand): '-e', '--env', type=int, - help='Show only nodes that are in the specified environment' - ) + help='Show only nodes that are in the specified environment') + + parser.add_argument( + '-l', + '--labels', + nargs='+', + help='Show only nodes that have specific labels') return parser def take_action(self, parsed_args): - - data = self.client.get_all(environment_id=parsed_args.env) + data = self.client.get_all( + environment_id=parsed_args.env, labels=parsed_args.labels) data = data_utils.get_display_data_multi(self.columns, data) return (self.columns, data) @@ -82,6 +87,37 @@ class NodeShow(NodeMixIn, base.BaseShowCommand): 'manufacturer') +class NodeUpdate(NodeMixIn, base.BaseShowCommand): + """Change given attributes for a node.""" + + columns = NodeShow.columns + + def get_parser(self, prog_name): + parser = super(NodeUpdate, self).get_parser(prog_name) + + parser.add_argument( + '-H', + '--hostname', + type=str, + default=None, + help='New hostname for node') + + return parser + + def take_action(self, parsed_args): + updates = {} + for attr in self.client._updatable_attributes: + if getattr(parsed_args, attr, None): + updates[attr] = getattr(parsed_args, attr) + + updated_node = self.client.update( + parsed_args.id, **updates) + updated_node = data_utils.get_display_data_single( + self.columns, updated_node) + + return (self.columns, updated_node) + + class NodeVmsList(NodeMixIn, base.BaseShowCommand): """Show list vms for node.""" @@ -118,31 +154,82 @@ class NodeCreateVMsConf(NodeMixIn, base.BaseCommand): self.app.stdout.write(msg) -class NodeUpdate(NodeMixIn, base.BaseShowCommand): - """Change given attributes for a node.""" +class NodeLabelList(NodeMixIn, base.BaseListCommand): + """Show list of all labels.""" - columns = NodeShow.columns + columns = ( + 'node_id', + 'label_name', + 'label_value') def get_parser(self, prog_name): - parser = super(NodeUpdate, self).get_parser(prog_name) + parser = super(NodeLabelList, self).get_parser(prog_name) - parser.add_argument('-H', - '--hostname', - type=str, - default=None, - help='New hostname for node') + parser.add_argument( + '-n', + '--nodes', + nargs='+', + help='Show labels for specific nodes') return parser def take_action(self, parsed_args): - updates = {} - for attr in self.client._updatable_attributes: - if getattr(parsed_args, attr, None): - updates[attr] = getattr(parsed_args, attr) + data = self.client.get_all_labels_for_nodes( + node_ids=parsed_args.nodes) + data = data_utils.get_display_data_multi(self.columns, data) - updated_node = self.client.update(parsed_args.id, - updates) - updated_node = data_utils.get_display_data_single(self.columns, - updated_node) + return (self.columns, data) - return (self.columns, updated_node) + +class NodeLabelSet(NodeMixIn, base.BaseCommand): + """Create or update specifc labels on nodes.""" + + def get_parser(self, prog_name): + parser = super(NodeLabelSet, self).get_parser(prog_name) + + parser.add_argument( + 'labels', + nargs='+', + help='List of labels for create or update') + + parser.add_argument( + '-n', + '--nodes', + nargs='+', + help='Create or update labels only for specific nodes') + + return parser + + def take_action(self, parsed_args): + data = self.client.set_labels_for_nodes( + labels=parsed_args.labels, node_ids=parsed_args.nodes) + msg = "Labels have been updated on nodes: {0} \n".format( + ','.join(data)) + self.app.stdout.write(msg) + + +class NodeLabelDelete(NodeMixIn, base.BaseCommand): + """Delete specific labels on nodes.""" + + def get_parser(self, prog_name): + parser = super(NodeLabelDelete, self).get_parser(prog_name) + + parser.add_argument( + 'labels_keys', + nargs='+', + help='List of labels keys for delete') + + parser.add_argument( + '-n', + '--nodes', + nargs='+', + help='Delete labels only for specific nodes') + + return parser + + def take_action(self, parsed_args): + data = self.client.delete_labels_for_nodes( + labels_keys=parsed_args.labels_keys, node_ids=parsed_args.nodes) + msg = "Labels have been deleted on nodes: {0} \n".format( + ','.join(data)) + self.app.stdout.write(msg) diff --git a/fuelclient/objects/node.py b/fuelclient/objects/node.py index 54157f1..c80b225 100644 --- a/fuelclient/objects/node.py +++ b/fuelclient/objects/node.py @@ -57,6 +57,10 @@ class Node(BaseObject): data = self.get_fresh_data() return data["progress"] + @property + def labels(self): + return self.get_fresh_data().get('labels', {}) + def get_attribute_default_url(self, attributes_type): url_path, default_url_path = self.attributes_urls[attributes_type] return "nodes/{0}/{1}/{2}".format(self.id, url_path, default_url_path) diff --git a/fuelclient/tests/utils/fake_node.py b/fuelclient/tests/utils/fake_node.py index e261744..80d2d97 100644 --- a/fuelclient/tests/utils/fake_node.py +++ b/fuelclient/tests/utils/fake_node.py @@ -17,7 +17,7 @@ def get_fake_node(cluster=None, hostname=None, node_id=None, cpu_model=None, roles=None, mac=None, memory_b=None, os_platform=None, - status=None, node_name=None, group_id=None): + status=None, node_name=None, group_id=None, labels=None): """Creates a fake node Returns the serialized and parametrized representation of a dumped Fuel @@ -25,6 +25,11 @@ def get_fake_node(cluster=None, hostname=None, node_id=None, cpu_model=None, """ host_name = hostname or 'fake-node-42' + labels = labels or { + 'key_1': 'val_1', + 'key_2': None, + 'key_3': 'val_3' + } return {'name': node_name or host_name, 'error_type': None, @@ -125,4 +130,5 @@ def get_fake_node(cluster=None, hostname=None, node_id=None, cpu_model=None, 'vlan': None}, {'dev': 'eth0', - 'name': 'admin'}]} + 'name': 'admin'}], + 'labels': labels} diff --git a/fuelclient/tests/v2/unit/cli/test_node.py b/fuelclient/tests/v2/unit/cli/test_node.py index b86cd3f..d0f900b 100644 --- a/fuelclient/tests/v2/unit/cli/test_node.py +++ b/fuelclient/tests/v2/unit/cli/test_node.py @@ -36,7 +36,8 @@ class TestNodeCommand(test_engine.BaseCLITest): self.exec_command(args) self.m_get_client.assert_called_once_with('node', mock.ANY) - self.m_client.get_all.assert_called_once_with(environment_id=None) + self.m_client.get_all.assert_called_once_with( + environment_id=None, labels=None) def test_node_list_with_env(self): env_id = 42 @@ -45,7 +46,31 @@ class TestNodeCommand(test_engine.BaseCLITest): self.exec_command(args) self.m_get_client.assert_called_once_with('node', mock.ANY) - self.m_client.get_all.assert_called_once_with(environment_id=env_id) + self.m_client.get_all.assert_called_once_with( + environment_id=env_id, labels=None) + + def test_node_list_with_labels(self): + labels = ['key_1=val_1', 'key_2=val_2'] + args = 'node list --labels {labels}'.format( + labels=' '.join(labels)) + + self.exec_command(args) + + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.get_all.assert_called_once_with( + environment_id=None, labels=labels) + + def test_node_list_with_env_and_labels(self): + env_id = 42 + labels = ["key_1=val_1", "key_2=val_2"] + args = 'node list --env {env} --labels {labels}'.format( + env=env_id, labels=' '.join(labels)) + + self.exec_command(args) + + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.get_all.assert_called_once_with( + environment_id=env_id, labels=labels) def test_node_show(self): node_id = 42 @@ -97,4 +122,70 @@ class TestNodeCommand(test_engine.BaseCLITest): self.m_get_client.assert_called_once_with('node', mock.ANY) self.m_client.update.assert_called_once_with( - node_id, {"hostname": hostname}) + node_id, **{"hostname": hostname}) + + def test_node_label_list_for_all_nodes(self): + args = 'node label list' + + self.exec_command(args) + + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.get_all_labels_for_nodes.assert_called_once_with( + node_ids=None) + + def test_node_label_list_for_specific_nodes(self): + node_ids = ['42', '43'] + args = 'node label list --nodes {node_ids}'.format( + node_ids=' '.join(node_ids)) + + self.exec_command(args) + + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.get_all_labels_for_nodes.assert_called_once_with( + node_ids=node_ids) + + def test_node_label_set_for_all_nodes(self): + labels = ['key_1=val_1', 'key_2=val_2'] + args = 'node label set {labels}'.format( + labels=' '.join(labels)) + + self.exec_command(args) + + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.set_labels_for_nodes.assert_called_once_with( + labels=labels, node_ids=None) + + def test_node_label_set_for_specific_nodes(self): + labels = ['key_1=val_1', 'key_2=val_2'] + node_ids = ['42', '43'] + args = 'node label set {labels} --nodes {node_ids}'.format( + labels=' '.join(labels), node_ids=' '.join(node_ids)) + + self.exec_command(args) + + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.set_labels_for_nodes.assert_called_once_with( + labels=labels, node_ids=node_ids) + + def test_node_label_delete_for_all_nodes(self): + labels_keys = ['key_1', 'key_2'] + args = 'node label delete {labels_keys}'.format( + labels_keys=' '.join(labels_keys)) + + self.exec_command(args) + + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.delete_labels_for_nodes.assert_called_once_with( + labels_keys=labels_keys, node_ids=None) + + def test_node_label_delete_for_specific_nodes(self): + labels_keys = ['key_1', 'key_2'] + node_ids = ['42', '43'] + args = 'node label delete {labels_keys} --nodes {node_ids}'.format( + labels_keys=' '.join(labels_keys), node_ids=' '.join(node_ids)) + + self.exec_command(args) + + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.delete_labels_for_nodes.assert_called_once_with( + labels_keys=labels_keys, node_ids=node_ids) diff --git a/fuelclient/tests/v2/unit/lib/test_node.py b/fuelclient/tests/v2/unit/lib/test_node.py index a0ccddb..e1c9642 100644 --- a/fuelclient/tests/v2/unit/lib/test_node.py +++ b/fuelclient/tests/v2/unit/lib/test_node.py @@ -87,3 +87,121 @@ class TestNodeFacade(test_api.BaseLibTest): self.assertTrue(matcher.called) self.assertEqual(data, matcher.last_request.json()) + + def test_get_all_labels_for_all_nodes(self): + matcher = self.m_request.get(self.res_uri, json=self.fake_nodes) + self.client.get_all_labels_for_nodes() + + self.assertTrue(matcher.called) + + def test_set_labels_for_all_nodes(self): + labels = ['key_1=val_1', 'key_2=val_2', 'key_3 = val_4'] + data = {'labels': { + 'key_1': 'val_1', + 'key_2': 'val_2', + 'key_3': 'val_4' + }} + + expected_uri = self.get_object_uri(self.res_uri, 42) + + matcher_get = self.m_request.get(self.res_uri, json=self.fake_nodes) + matcher_put = self.m_request.put(expected_uri, json=data) + + self.client.set_labels_for_nodes(labels=labels) + + self.assertTrue(matcher_get.called) + self.assertTrue(matcher_put.called) + self.assertEqual(data, matcher_put.last_request.json()) + + def test_set_labels_for_specific_nodes(self): + labels = ['key_1=val_1', 'key_2=val_2', 'key_3 = val_4'] + node_ids = ['42'] + data = {'labels': { + 'key_1': 'val_1', + 'key_2': 'val_2', + 'key_3': 'val_4' + }} + expected_uri = self.get_object_uri(self.res_uri, 42) + + matcher_get = self.m_request.get(expected_uri, json=self.fake_node) + matcher_put = self.m_request.put(expected_uri, json=data) + + self.client.set_labels_for_nodes( + labels=labels, node_ids=node_ids) + + self.assertTrue(matcher_get.called) + self.assertTrue(matcher_put.called) + self.assertEqual(data, matcher_put.last_request.json()) + + def test_delete_labels_for_all_nodes(self): + labels_keys = ['key_1', ' key_3 '] + data = {'labels': {'key_2': None}} + expected_uri = self.get_object_uri(self.res_uri, 42) + + matcher_get = self.m_request.get(self.res_uri, json=self.fake_nodes) + matcher_put = self.m_request.put(expected_uri, json=data) + + self.client.delete_labels_for_nodes(labels_keys=labels_keys) + + self.assertTrue(matcher_get.called) + self.assertTrue(matcher_put.called) + self.assertEqual(data, matcher_put.last_request.json()) + + def test_delete_labels_for_specific_nodes(self): + labels_keys = ['key_2'] + node_ids = ['42'] + data = {'labels': {'key_1': 'val_1', 'key_3': 'val_3'}} + expected_uri = self.get_object_uri(self.res_uri, 42) + + matcher_get = self.m_request.get(expected_uri, json=self.fake_node) + matcher_put = self.m_request.put(expected_uri, json=data) + + self.client.delete_labels_for_nodes( + labels_keys=labels_keys, node_ids=node_ids) + + self.assertTrue(matcher_get.called) + self.assertTrue(matcher_put.called) + self.assertEqual(data, matcher_put.last_request.json()) + + def test_get_name_and_value_from_labels(self): + for label in ( + 'key=value', + ' key =value', + 'key= value ', + ' key = value ', + ): + name, value = self.client._split_label(label) + self.assertEqual(name, 'key') + self.assertEqual(value, 'value') + + def test_get_name_and_empty_value_from_lables(self): + for label in ( + 'key=', + ' key =', + 'key= ', + ' key = ', + ): + name, value = self.client._split_label(label) + self.assertEqual(name, 'key') + self.assertIsNone(value) + + def test_labels_after_delete(self): + all_labels = { + 'label_A': 'A', + 'label_B': None, + 'label_C': 'C', + 'label_D': None, + } + expected_labels = { + 'label_A': 'A', + 'label_D': None, + } + + for labels_to_delete in ( + ('label_B', 'label_C'), + (' label_B', 'label_C '), + ('label_B', 'label_C ', 'unknown_label'), + ): + result = self.client._labels_after_delete( + all_labels, labels_to_delete) + self.assertEqual(result, expected_labels) diff --git a/fuelclient/v1/node.py b/fuelclient/v1/node.py index 2f9e05b..8377554 100644 --- a/fuelclient/v1/node.py +++ b/fuelclient/v1/node.py @@ -11,6 +11,10 @@ # 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 functools import partial + import six from fuelclient.cli import error @@ -21,14 +25,26 @@ from fuelclient.v1 import base_v1 class NodeClient(base_v1.BaseV1Client): _entity_wrapper = objects.Node - _updatable_attributes = ('hostname',) + _updatable_attributes = ('hostname', 'labels') - def get_all(self, environment_id=None): + def get_all(self, environment_id=None, labels=None): + """Get nodes by specific environment or labels + + :param environment_id: Id of specific environment(cluster) + :type environment_id: int + :param labels: List of string labels for filtering nodes + :type labels: list + :returns: list -- filtered list of nodes + """ result = self._entity_wrapper.get_all_data() if environment_id is not None: result = filter(lambda n: n['cluster'] == environment_id, result) + if labels: + result = filter( + partial(self._check_label, labels), result) + return result def get_node_vms_conf(self, node_id): @@ -40,14 +56,139 @@ class NodeClient(base_v1.BaseV1Client): return node.node_vms_create(config) def update(self, node_id, **updated_attributes): + node = self._entity_wrapper(obj_id=node_id) + for attr in six.iterkeys(updated_attributes): if attr not in self._updatable_attributes: msg = 'Only {0} are updatable'.format( self._updatable_attributes) raise error.ArgumentException(msg) - node = self._entity_wrapper(obj_id=node_id) + return node.set(updated_attributes) + def get_all_labels_for_nodes(self, node_ids=None): + """Get list of labels for specific nodes. If no node_ids then all + labels should be returned + + :param node_ids: List of node ids for filtering labels + :type node_ids: list + :returns: list -- filtered list of labels + """ + labels = [] + + result = self._entity_wrapper.get_all_data() + + if node_ids: + result = filter(lambda node: str(node['id']) in node_ids, result) + + for node in result: + for key, value in six.iteritems(node.get('labels', [])): + labels.append({ + 'node_id': node.get('id'), + 'label_name': key, + 'label_value': value + }) + + labels = sorted(labels, key=lambda label: label.get('node_id')) + + return labels + + def set_labels_for_nodes(self, labels=None, node_ids=None): + """Update nodes labels attribute with new data. If node_ids + are empty list then labels will be updated on all nodes + + :param labels: List of string pairs `key=val` for labels + :type labels: list + :param node_ids: List of node ids where labels should be updated + :type node_ids: list + :return: list -- ids of nodes where labels were updated + """ + data_to_return = [] + labels_to_update = {} + + for label in labels: + key, val = self._split_label(label) + labels_to_update[key] = val + + if node_ids: + for node_id in node_ids: + node = self._entity_wrapper(obj_id=node_id) + db_labels = copy.deepcopy(node.labels) + db_labels.update(labels_to_update) + + result = self.update(node_id, **{'labels': db_labels}) + data_to_return.append(str(result.get('id'))) + else: + nodes = self._entity_wrapper.get_all_data() + for node in nodes: + db_labels = copy.deepcopy(node['labels']) + db_labels.update(labels_to_update) + + result = self.update(node['id'], **{'labels': db_labels}) + data_to_return.append(str(result.get('id'))) + + return data_to_return + + def delete_labels_for_nodes(self, labels_keys=None, node_ids=None): + """Delete labels data from nodes labels. If node_ids are + empty list then labels will be deleted on all nodes + + :param labels: List of string label keys + :type labels: list + :param node_ids: List of node ids where labels should be deleted + :type node_ids: list + :returns: list -- ids of nodes where labels were deleted + """ + data_to_return = [] + + if node_ids: + for node_id in node_ids: + node = self._entity_wrapper(obj_id=node_id) + updated_labels = self._labels_after_delete( + node.labels, labels_keys) + + result = self.update(node_id, **{'labels': updated_labels}) + data_to_return.append(str(result.get('id'))) + else: + nodes = self._entity_wrapper.get_all_data() + for node in nodes: + updated_labels = self._labels_after_delete( + node['labels'], labels_keys) + + result = self.update(node['id'], **{'labels': updated_labels}) + data_to_return.append(str(result.get('id'))) + + return data_to_return + + def _check_label(self, labels, item): + checking_list = [] + + for label in labels: + key, val = self._split_label(label) + + if key in item.get('labels'): + checking_val = item['labels'][key] == val + checking_list.append(checking_val) + + return True in checking_list + + @staticmethod + def _labels_after_delete(labels, labels_keys): + db_labels = copy.deepcopy(labels) + for label_key in labels_keys: + label_key = label_key.strip() + db_labels.pop(label_key, None) + + return db_labels + + @staticmethod + def _split_label(label): + name, value = label.split('=') + name = name.strip() + value = value.strip() + value = None if value == '' else value + return name, value + def get_client(): return NodeClient() diff --git a/setup.cfg b/setup.cfg index 22af400..b6f2fea 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,6 +42,9 @@ fuelclient = node_list-vms-conf=fuelclient.commands.node:NodeVmsList node_create-vms-conf=fuelclient.commands.node:NodeCreateVMsConf node_update=fuelclient.commands.node:NodeUpdate + node_label_list=fuelclient.commands.node:NodeLabelList + node_label_set=fuelclient.commands.node:NodeLabelSet + node_label_delete=fuelclient.commands.node:NodeLabelDelete task_list=fuelclient.commands.task:TaskList task_show=fuelclient.commands.task:TaskShow fuel-version=fuelclient.commands.fuelversion:FuelVersion