diff --git a/fuelclient/commands/node.py b/fuelclient/commands/node.py index 8646a3b..d11a01c 100644 --- a/fuelclient/commands/node.py +++ b/fuelclient/commands/node.py @@ -12,11 +12,13 @@ # License for the specific language governing permissions and limitations # under the License. +import abc import collections import json import operator import os +from oslo_utils import fileutils import six from fuelclient.cli import error @@ -45,6 +47,124 @@ class NodeMixIn(object): return numa_topology_info +@six.add_metaclass(abc.ABCMeta) +class BaseUploadCommand(NodeMixIn, base.BaseCommand): + """Base class for uploading attributes of a node.""" + + @abc.abstractproperty + def attribute(self): + """String with the name of the attribute.""" + pass + + @abc.abstractproperty + def uploader(self): + """Callable for uploading data.""" + pass + + def get_parser(self, prog_name): + parser = super(BaseUploadCommand, self).get_parser(prog_name) + parser.add_argument('id', + type=int, + help='Id of a node.') + parser.add_argument('-f', + '--format', + required=True, + choices=self.supported_file_formats, + help='Format of serialized ' + '{} data.'.format(self.attribute)) + parser.add_argument('-d', + '--directory', + required=False, + default=os.curdir, + help='Source directory. Defaults to ' + 'the current directory.') + + return parser + + def take_action(self, parsed_args): + directory = parsed_args.directory + + file_path = self.get_attributes_path(self.attribute, + parsed_args.format, + parsed_args.id, + directory) + + try: + with open(file_path, 'r') as stream: + attributes = data_utils.safe_load(parsed_args.format, + stream) + self.uploader(parsed_args.id, attributes) + except (OSError, IOError): + msg = 'Could not read configuration of {} at {}.' + raise error.InvalidFileException(msg.format(self.attribute, + file_path)) + + msg = ('Configuration of {t} for node with id ' + '{node} was loaded from {path}\n') + self.app.stdout.write(msg.format(t=self.attribute, + node=parsed_args.id, + path=file_path)) + + +@six.add_metaclass(abc.ABCMeta) +class BaseDownloadCommand(NodeMixIn, base.BaseCommand): + """Base class for downloading attributes of a node.""" + + @abc.abstractproperty + def attribute(self): + """String with the name of the attribute.""" + pass + + @abc.abstractproperty + def downloader(self): + """Callable for downloading data.""" + pass + + def get_parser(self, prog_name): + parser = super(BaseDownloadCommand, self).get_parser(prog_name) + parser.add_argument('id', + type=int, + help='Id of a node.') + parser.add_argument('-f', + '--format', + required=True, + choices=self.supported_file_formats, + help='Format of serialized ' + '{} data.'.format(self.attribute)) + parser.add_argument('-d', + '--directory', + required=False, + default=os.curdir, + help='Destination directory. Defaults to ' + 'the current directory.') + return parser + + def take_action(self, parsed_args): + directory = parsed_args.directory + attributes = self.downloader(parsed_args.id) + file_path = self.get_attributes_path(self.attribute, + parsed_args.format, + parsed_args.id, + directory) + + try: + fileutils.ensure_tree(os.path.dirname(file_path)) + fileutils.delete_if_exists(file_path) + + with open(file_path, 'w') as stream: + data_utils.safe_dump(parsed_args.format, stream, attributes) + except (OSError, IOError): + msg = 'Could not store configuration of {} at {}.' + raise error.InvalidFileException(msg.format(self.attribute, + file_path)) + + msg = ('Configuration of {t} for node with id ' + '{node} was stored in {path}\n') + self.app.stdout.write(msg.format(t=self.attribute, + node=parsed_args.id, + path=file_path)) + + class NodeList(NodeMixIn, base.BaseListCommand): """Show list of all available nodes.""" @@ -393,3 +513,63 @@ class NodeAnsibleInventory(NodeMixIn, base.BaseCommand): ) ) self.app.stdout.write(u'\n\n') + + +class NodeInterfacesDownload(BaseDownloadCommand): + """Download and store configuration of interfaces for a node to a file.""" + + attribute = 'interfaces' + + @property + def downloader(self): + return self.client.get_interfaces + + +class NodeInterfacesGetDefault(BaseDownloadCommand): + """Download default configuration of interfaces for a node to a file.""" + + attribute = 'interfaces' + + @property + def downloader(self): + return self.client.get_default_interfaces + + +class NodeInterfacesUpload(BaseUploadCommand): + """Upload stored configuration of interfaces for a node from a file.""" + + attribute = 'interfaces' + + @property + def uploader(self): + return self.client.set_interfaces + + +class NodeDisksDownload(BaseDownloadCommand): + """Download and store configuration of disks for a node to a file.""" + + attribute = 'disks' + + @property + def downloader(self): + return self.client.get_disks + + +class NodeDisksGetDefault(BaseDownloadCommand): + """Download default configuration of disks for a node to a file.""" + + attribute = 'disks' + + @property + def downloader(self): + return self.client.get_default_disks + + +class NodeDisksUpload(BaseUploadCommand): + """Upload stored configuration of disks for a node from a file.""" + + attribute = 'disks' + + @property + def uploader(self): + return self.client.set_disks diff --git a/fuelclient/tests/unit/v2/cli/test_node.py b/fuelclient/tests/unit/v2/cli/test_node.py index ec7bdf7..c862b97 100644 --- a/fuelclient/tests/unit/v2/cli/test_node.py +++ b/fuelclient/tests/unit/v2/cli/test_node.py @@ -15,9 +15,11 @@ # under the License. import io +import json import mock import six +import yaml from fuelclient import main as main_mod from fuelclient.tests.unit.v2.cli import test_engine @@ -290,6 +292,104 @@ node-4 ansible_host=10.20.0.5 self.m_client.delete_labels_for_nodes.assert_called_once_with( labels=labels, node_ids=None) + @mock.patch('json.dump') + def test_node_disks_download_json(self, m_dump): + args = 'node disks download --format json -d /tmp 42' + test_data = {'foo': 'bar'} + expected_path = '/tmp/node_42/disks.json' + + self.m_client.get_disks.return_value = test_data + + m_open = mock.mock_open() + with mock.patch('fuelclient.commands.node.open', m_open, create=True): + self.exec_command(args) + + m_open.assert_called_once_with(expected_path, 'w') + m_dump.assert_called_once_with(test_data, mock.ANY, indent=4) + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.get_disks.assert_called_once_with(42) + + def test_node_disks_upload_json(self): + args = 'node disks upload --format json -d /tmp 42' + test_data = {'foo': 'bar'} + expected_path = '/tmp/node_42/disks.json' + + m_open = mock.mock_open(read_data=json.dumps(test_data)) + with mock.patch('fuelclient.commands.node.open', m_open, create=True): + self.exec_command(args) + + m_open.assert_called_once_with(expected_path, 'r') + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.set_disks.assert_called_once_with(42, test_data) + + @mock.patch('json.dump') + def test_node_disks_getdefault_json(self, m_dump): + args = 'node disks get-default --format json -d /tmp 42' + test_data = {'foo': 'bar'} + expected_path = '/tmp/node_42/disks.json' + + self.m_client.get_default_disks.return_value = test_data + + m_open = mock.mock_open() + with mock.patch('fuelclient.commands.node.open', m_open, create=True): + self.exec_command(args) + + m_open.assert_called_once_with(expected_path, 'w') + m_dump.assert_called_once_with(test_data, mock.ANY, indent=4) + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.get_default_disks.assert_called_once_with(42) + + @mock.patch('yaml.safe_dump') + def test_node_disks_download_yaml(self, m_safe_dump): + args = 'node disks download --format yaml -d /tmp 42' + test_data = {'foo': 'bar'} + expected_path = '/tmp/node_42/disks.yaml' + + self.m_client.get_disks.return_value = test_data + + m_open = mock.mock_open() + with mock.patch('fuelclient.commands.node.open', m_open, create=True): + self.exec_command(args) + + m_open.assert_called_once_with(expected_path, 'w') + m_safe_dump.assert_called_once_with(test_data, mock.ANY, + default_flow_style=False) + + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.get_disks.assert_called_once_with(42) + + def test_node_disks_upload_yaml(self): + args = 'node disks upload --format yaml -d /tmp 42' + test_data = {'foo': 'bar'} + expected_path = '/tmp/node_42/disks.yaml' + + m_open = mock.mock_open(read_data=yaml.dump(test_data)) + with mock.patch('fuelclient.commands.node.open', m_open, create=True): + self.exec_command(args) + + m_open.assert_called_once_with(expected_path, 'r') + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.set_disks.assert_called_once_with(42, test_data) + + @mock.patch('yaml.safe_dump') + def test_node_disks_getdefault_yaml(self, m_safe_dump): + args = 'node disks get-default --format yaml -d /tmp 42' + test_data = {'foo': 'bar'} + expected_path = '/tmp/node_42/disks.yaml' + + self.m_client.get_default_disks.return_value = test_data + + m_open = mock.mock_open() + with mock.patch('fuelclient.commands.node.open', m_open, create=True): + self.exec_command(args) + + m_open.assert_called_once_with(expected_path, 'w') + m_safe_dump.assert_called_once_with(test_data, mock.ANY, + default_flow_style=False) + + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.get_default_disks.assert_called_once_with(42) + def test_node_delete_specific_labels_for_specific_nodes(self): labels_keys = ['key_1', 'key_2'] node_ids = ['42', '43'] @@ -362,3 +462,101 @@ node-4 ansible_host=10.20.0.5 self.m_get_client.assert_called_once_with('node', mock.ANY) self.m_client.upload_attributes.assert_called_once_with(42, None) + + @mock.patch('json.dump') + def test_node_interfaces_download_json(self, m_dump): + args = 'node interfaces download --format json -d /tmp 42' + test_data = {'foo': 'bar'} + expected_path = '/tmp/node_42/interfaces.json' + + self.m_client.get_interfaces.return_value = test_data + + m_open = mock.mock_open() + with mock.patch('fuelclient.commands.node.open', m_open, create=True): + self.exec_command(args) + + m_open.assert_called_once_with(expected_path, 'w') + m_dump.assert_called_once_with(test_data, mock.ANY, indent=4) + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.get_interfaces.assert_called_once_with(42) + + def test_node_interfaces_upload_json(self): + args = 'node interfaces upload --format json -d /tmp 42' + test_data = {'foo': 'bar'} + expected_path = '/tmp/node_42/interfaces.json' + + m_open = mock.mock_open(read_data=json.dumps(test_data)) + with mock.patch('fuelclient.commands.node.open', m_open, create=True): + self.exec_command(args) + + m_open.assert_called_once_with(expected_path, 'r') + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.set_interfaces.assert_called_once_with(42, test_data) + + @mock.patch('json.dump') + def test_node_interfaces_getdefault_json(self, m_dump): + args = 'node interfaces get-default --format json -d /tmp 42' + test_data = {'foo': 'bar'} + expected_path = '/tmp/node_42/interfaces.json' + + self.m_client.get_default_interfaces.return_value = test_data + + m_open = mock.mock_open() + with mock.patch('fuelclient.commands.node.open', m_open, create=True): + self.exec_command(args) + + m_open.assert_called_once_with(expected_path, 'w') + m_dump.assert_called_once_with(test_data, mock.ANY, indent=4) + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.get_default_interfaces.assert_called_once_with(42) + + @mock.patch('yaml.safe_dump') + def test_node_interfaces_download_yaml(self, m_safe_dump): + args = 'node interfaces download --format yaml -d /tmp 42' + test_data = {'foo': 'bar'} + expected_path = '/tmp/node_42/interfaces.yaml' + + self.m_client.get_interfaces.return_value = test_data + + m_open = mock.mock_open() + with mock.patch('fuelclient.commands.node.open', m_open, create=True): + self.exec_command(args) + + m_open.assert_called_once_with(expected_path, 'w') + m_safe_dump.assert_called_once_with(test_data, mock.ANY, + default_flow_style=False) + + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.get_interfaces.assert_called_once_with(42) + + def test_node_interfaces_upload_yaml(self): + args = 'node interfaces upload --format yaml -d /tmp 42' + test_data = {'foo': 'bar'} + expected_path = '/tmp/node_42/interfaces.yaml' + + m_open = mock.mock_open(read_data=yaml.dump(test_data)) + with mock.patch('fuelclient.commands.node.open', m_open, create=True): + self.exec_command(args) + + m_open.assert_called_once_with(expected_path, 'r') + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.set_interfaces.assert_called_once_with(42, test_data) + + @mock.patch('yaml.safe_dump') + def test_node_interfaces_getdefault_yaml(self, m_safe_dump): + args = 'node interfaces get-default --format yaml -d /tmp 42' + test_data = {'foo': 'bar'} + expected_path = '/tmp/node_42/interfaces.yaml' + + self.m_client.get_default_interfaces.return_value = test_data + + m_open = mock.mock_open() + with mock.patch('fuelclient.commands.node.open', m_open, create=True): + self.exec_command(args) + + m_open.assert_called_once_with(expected_path, 'w') + m_safe_dump.assert_called_once_with(test_data, mock.ANY, + default_flow_style=False) + + self.m_get_client.assert_called_once_with('node', mock.ANY) + self.m_client.get_default_interfaces.assert_called_once_with(42) diff --git a/fuelclient/tests/unit/v2/lib/test_node.py b/fuelclient/tests/unit/v2/lib/test_node.py index 6541f47..a52330b 100644 --- a/fuelclient/tests/unit/v2/lib/test_node.py +++ b/fuelclient/tests/unit/v2/lib/test_node.py @@ -15,6 +15,7 @@ # under the License. import mock +import os import yaml import fuelclient @@ -360,6 +361,58 @@ class TestNodeFacade(test_api.BaseLibTest): m_open().write.assert_called_once_with( serializer.serialize(fake_attributes)) + def test_node_disks_upload(self): + node_id = 42 + new_disks = {'test_key': u'test ☃ value'} + + node_uri = self.get_object_uri(self.res_uri, node_id) + disks_uri = os.path.join(node_uri, 'disks/') + + get_matcher = self.m_request.get(node_uri, json=self.fake_node) + upd_matcher = self.m_request.put(disks_uri, json={}) + + self.client.set_disks(node_id, new_disks) + + self.assertTrue(node_uri, get_matcher.called) + self.assertTrue(disks_uri, upd_matcher.called) + + req_data = upd_matcher.last_request.json() + self.assertEqual(new_disks, req_data) + + def test_node_disks_download(self): + node_id = 42 + fake_resp = {'test_key': u'test ☃ value'} + + node_uri = self.get_object_uri(self.res_uri, node_id) + disks_uri = os.path.join(node_uri, 'disks/') + + node_matcher = self.m_request.get(node_uri, json=self.fake_node) + disks_matcher = self.m_request.get(disks_uri, json=fake_resp) + + disks = self.client.get_disks(node_id) + + self.assertTrue(node_uri, node_matcher.called) + self.assertTrue(disks_uri, disks_matcher.called) + + self.assertEqual(disks, fake_resp) + + def test_node_disks_defaults(self): + node_id = 42 + fake_resp = {'test_key': u'test ☃ value'} + + node_uri = self.get_object_uri(self.res_uri, node_id) + disks_uri = os.path.join(node_uri, 'disks/defaults') + + node_matcher = self.m_request.get(node_uri, json=self.fake_node) + disks_matcher = self.m_request.get(disks_uri, json=fake_resp) + + disks = self.client.get_default_disks(node_id) + + self.assertTrue(node_uri, node_matcher.called) + self.assertTrue(disks_uri, disks_matcher.called) + + self.assertEqual(disks, fake_resp) + @mock.patch('fuelclient.objects.node.os.path.exists', mock.Mock(return_value=True)) def test_node_attribute_upload(self): @@ -382,3 +435,56 @@ class TestNodeFacade(test_api.BaseLibTest): '/fake/dir/node_{0}/attributes.yaml'.format(node_id), mock.ANY) self.assertEqual(m_put.last_request.json(), fake_attributes) m_open().read.assert_called_once_with() + + def test_node_interfaces_upload(self): + node_id = 42 + new_interfaces = {'test_key': u'test ☃ value'} + + node_uri = self.get_object_uri(self.res_uri, node_id) + interfaces_uri = os.path.join(node_uri, 'interfaces/') + + get_matcher = self.m_request.get(node_uri, json=self.fake_node) + upd_matcher = self.m_request.put(interfaces_uri, json={}) + + self.client.set_interfaces(node_id, new_interfaces) + + self.assertTrue(node_uri, get_matcher.called) + self.assertTrue(interfaces_uri, upd_matcher.called) + + req_data = upd_matcher.last_request.json() + self.assertEqual(new_interfaces, req_data) + + def test_node_interfaces_download(self): + node_id = 42 + fake_resp = {'test_key': u'test ☃ value'} + + node_uri = self.get_object_uri(self.res_uri, node_id) + interfaces_uri = os.path.join(node_uri, 'interfaces/') + + node_matcher = self.m_request.get(node_uri, json=self.fake_node) + interfaces_matcher = self.m_request.get(interfaces_uri, json=fake_resp) + + interfaces = self.client.get_interfaces(node_id) + + self.assertTrue(node_uri, node_matcher.called) + self.assertTrue(interfaces_uri, interfaces_matcher.called) + + self.assertEqual(interfaces, fake_resp) + + def test_node_interfaces_default(self): + node_id = 42 + fake_resp = {'test_key': u'test ☃ value'} + + node_uri = self.get_object_uri(self.res_uri, node_id) + interfaces_uri = os.path.join(node_uri, + 'interfaces/default_assignment') + + node_matcher = self.m_request.get(node_uri, json=self.fake_node) + interfaces_matcher = self.m_request.get(interfaces_uri, json=fake_resp) + + interfaces = self.client.get_default_interfaces(node_id) + + self.assertTrue(node_uri, node_matcher.called) + self.assertTrue(interfaces_uri, interfaces_matcher.called) + + self.assertEqual(interfaces, fake_resp) diff --git a/fuelclient/v1/node.py b/fuelclient/v1/node.py index bebe706..011cff6 100644 --- a/fuelclient/v1/node.py +++ b/fuelclient/v1/node.py @@ -168,11 +168,74 @@ class NodeClient(base_v1.BaseV1Client): return node.write_attribute( 'attributes', attributes, directory=directory) + def get_disks(self, node_id): + """Download configuration of disks for a node + + :param node_id: Id of a node. + :return: dict with the configuration of disks for the node. + + """ + node = self._entity_wrapper(node_id) + return node.get_attribute('disks') + + def get_default_disks(self, node_id): + """Download default configuration of disks for a node + + :param node_id: Id of a node. + :return: dict with the default configuration of disks + for the node. + + """ + node = self._entity_wrapper(node_id) + return node.get_default_attribute('disks') + + def set_disks(self, node_id, disks): + """Upload and set configuration of disks for a node + + :param node_id: Id of a node. + :param interfaces: dict that contains valid configuration + for disks + + """ + node = self._entity_wrapper(node_id) + return node.upload_node_attribute('disks', disks) + def upload_attributes(self, node_id, directory=None): node = self._entity_wrapper(node_id) attributes = node.read_attribute('attributes', directory=directory) node.update_node_attributes(attributes) + def get_interfaces(self, node_id): + """Download configuration of interfaces for a node + + :param node_id: Id of a node. + :return: dict with the configuration of interfaces for the node. + + """ + node = self._entity_wrapper(node_id) + return node.get_attribute('interfaces') + + def get_default_interfaces(self, node_id): + """Download default configuration of interfaces for a node + + :param node_id: Id of a node. + :return: dict with the configuration of interfaces for the node. + + """ + node = self._entity_wrapper(node_id) + return node.get_default_attribute('interfaces') + + def set_interfaces(self, node_id, interfaces): + """Upload and set configuration of interfaces for a node + + :param node_id: Id of a node. + :param interfaces: dict that contains valid configuration + for interfaces + + """ + node = self._entity_wrapper(node_id) + return node.upload_node_attribute('interfaces', interfaces) + def _check_label(self, labels, item): checking_list = [] diff --git a/setup.cfg b/setup.cfg index 93e8741..b4966e7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,6 +63,12 @@ fuelclient = node_attributes-download=fuelclient.commands.node:NodeAttributesDownload node_attributes-upload=fuelclient.commands.node:NodeAttributesUpload node_create-vms-conf=fuelclient.commands.node:NodeCreateVMsConf + node_interfaces_download=fuelclient.commands.node:NodeInterfacesDownload + node_interfaces_get-default=fuelclient.commands.node:NodeInterfacesGetDefault + node_interfaces_upload=fuelclient.commands.node:NodeInterfacesUpload + node_disks_download=fuelclient.commands.node:NodeDisksDownload + node_disks_get-default=fuelclient.commands.node:NodeDisksGetDefault + node_disks_upload=fuelclient.commands.node:NodeDisksUpload node_label_delete=fuelclient.commands.node:NodeLabelDelete node_label_list=fuelclient.commands.node:NodeLabelList node_label_set=fuelclient.commands.node:NodeLabelSet