diff --git a/fuelclient/commands/plugins.py b/fuelclient/commands/plugins.py index 7949a23..ef1df34 100644 --- a/fuelclient/commands/plugins.py +++ b/fuelclient/commands/plugins.py @@ -18,6 +18,48 @@ from fuelclient.commands import base class PluginsMixIn(object): entity_name = 'plugins' + @staticmethod + def add_plugin_file_argument(parser): + parser.add_argument( + 'file', + type=str, + help='Path to plugin file to install' + ) + + @staticmethod + def add_plugin_name_argument(parser): + parser.add_argument( + 'name', + type=str, + help='Name of plugin to remove' + ) + + @staticmethod + def add_plugin_version_argument(parser): + parser.add_argument( + 'version', + type=str, + help='Version of plugin to remove' + ) + + @staticmethod + def add_plugin_ids_argument(parser): + parser.add_argument( + 'ids', + type=int, + nargs='*', + metavar='plugin-id', + help='Synchronise only plugins with specified ids' + ) + + @staticmethod + def add_plugin_install_force_argument(parser): + parser.add_argument( + '-f', '--force', + action='store_true', + help='Used for reinstall plugin with the same version' + ) + class PluginsList(PluginsMixIn, base.BaseListCommand): """Show list of all available plugins.""" @@ -34,16 +76,42 @@ class PluginsSync(PluginsMixIn, base.BaseCommand): def get_parser(self, prog_name): parser = super(PluginsSync, self).get_parser(prog_name) - parser.add_argument( - 'ids', - type=int, - nargs='*', - metavar='plugin-id', - help='Synchronise only plugins with specified ids') - + self.add_plugin_ids_argument(parser) return parser def take_action(self, parsed_args): ids = parsed_args.ids if len(parsed_args.ids) > 0 else None self.client.sync(ids=ids) self.app.stdout.write("Plugins were successfully synchronized.\n") + + +class PluginInstall(PluginsMixIn, base.BaseCommand): + """Install plugin archive and register in API service.""" + + def get_parser(self, prog_name): + parser = super(PluginInstall, self).get_parser(prog_name) + self.add_plugin_file_argument(parser) + self.add_plugin_install_force_argument(parser) + return parser + + def take_action(self, parsed_args): + self.client.install(parsed_args.file, force=parsed_args.force) + self.app.stdout.write( + "Plugin {0} was successfully installed.\n".format(parsed_args.file) + ) + + +class PluginRemove(PluginsMixIn, base.BaseCommand): + """Remove the plugin package, and update data in API service.""" + + def get_parser(self, prog_name): + parser = super(PluginRemove, self).get_parser(prog_name) + self.add_plugin_name_argument(parser) + self.add_plugin_version_argument(parser) + return parser + + def take_action(self, parsed_args): + self.client.remove(parsed_args.name, parsed_args.version) + self.app.stdout.write( + "Plugin {0} was successfully removed.\n".format(parsed_args.name) + ) diff --git a/fuelclient/objects/plugins.py b/fuelclient/objects/plugins.py index 73d842b..b9bea5e 100644 --- a/fuelclient/objects/plugins.py +++ b/fuelclient/objects/plugins.py @@ -336,6 +336,10 @@ class Plugins(base.BaseObject): :return: Plugins information :rtype: dict """ + if not utils.file_exists(plugin_path): + raise error.BadDataException( + "No such plugin file: {0}".format(plugin_path) + ) plugin = cls.make_obj_by_file(plugin_path) name = plugin.name_from_file(plugin_path) diff --git a/fuelclient/tests/unit/v1/test_plugins_object.py b/fuelclient/tests/unit/v1/test_plugins_object.py index bb6b4ca..c959b5d 100644 --- a/fuelclient/tests/unit/v1/test_plugins_object.py +++ b/fuelclient/tests/unit/v1/test_plugins_object.py @@ -25,6 +25,7 @@ from fuelclient.objects.plugins import Plugins from fuelclient.objects.plugins import PluginV1 from fuelclient.objects.plugins import PluginV2 from fuelclient.tests.unit.v1 import base +from fuelclient import utils @patch('fuelclient.objects.plugins.raise_error_if_not_master') @@ -220,9 +221,11 @@ class TestPluginsObject(base.UnitTestCase): get_mock.assert_called_once_with(self.name, self.version) del_mock.assert_called_once_with('plugins/123') + @patch.object(utils, 'file_exists', return_value=True) @patch.object(Plugins, 'register') @patch.object(Plugins, 'make_obj_by_file') - def test_install(self, make_obj_by_file_mock, register_mock): + def test_install(self, make_obj_by_file_mock, register_mock, + file_exists_mock): plugin_obj = self.mock_make_obj_by_file(make_obj_by_file_mock) register_mock.return_value = {'id': 1} self.plugin.install(self.path) @@ -230,6 +233,7 @@ class TestPluginsObject(base.UnitTestCase): plugin_obj.install.assert_called_once_with(self.path, force=False) register_mock.assert_called_once_with( 'retrieved_name', 'retrieved_version', force=False) + file_exists_mock.assert_called_once_with(self.path) @patch.object(Plugins, 'unregister') @patch.object(Plugins, 'make_obj_by_name') diff --git a/fuelclient/tests/unit/v2/cli/test_plugins.py b/fuelclient/tests/unit/v2/cli/test_plugins.py index 629810a..95bf9e3 100644 --- a/fuelclient/tests/unit/v2/cli/test_plugins.py +++ b/fuelclient/tests/unit/v2/cli/test_plugins.py @@ -15,16 +15,19 @@ # under the License. import mock +import tempfile from fuelclient.tests.unit.v2.cli import test_engine from fuelclient.tests.utils import fake_plugin class TestPluginsCommand(test_engine.BaseCLITest): - """Tests for fuel2 node * commands.""" + """Tests for fuel2 plugins * commands.""" def setUp(self): super(TestPluginsCommand, self).setUp() + self.name = 'fuel_plugin' + self.version = '1.0.0' get_fake_plugins = fake_plugin.get_fake_plugins @@ -57,3 +60,25 @@ class TestPluginsCommand(test_engine.BaseCLITest): self.m_get_client.assert_called_once_with('plugins', mock.ANY) self.m_client.sync.assert_called_once_with(ids=ids) + + def exec_install(self, ext='rpm', force=False): + path = tempfile.mkstemp(suffix='.{}'.format(ext))[1] + args = 'plugins install {0} {1}'.format(path, + '--force' if force else '') + self.exec_command(args) + + self.m_get_client.assert_called_once_with('plugins', mock.ANY) + self.m_client.install.assert_called_once_with(path, force=force) + + def test_plugin_install(self): + self.exec_install() + + def test_plugin_install_with_force(self): + self.exec_install(force=True) + + def test_plugin_remove(self): + args = 'plugins remove {0} {1}'.format(self.name, self.version) + self.exec_command(args) + + self.m_get_client.assert_called_once_with('plugins', mock.ANY) + self.m_client.remove.assert_called_once_with(self.name, self.version) diff --git a/fuelclient/tests/unit/v2/lib/test_plugins.py b/fuelclient/tests/unit/v2/lib/test_plugins.py index 59c174d..24681f9 100644 --- a/fuelclient/tests/unit/v2/lib/test_plugins.py +++ b/fuelclient/tests/unit/v2/lib/test_plugins.py @@ -13,8 +13,12 @@ # 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 json + +from mock import mock import fuelclient +from fuelclient.cli import error from fuelclient.tests.unit.v2.lib import test_api from fuelclient.tests import utils @@ -63,3 +67,104 @@ class TestPluginsFacade(test_api.BaseLibTest): self.client.sync(ids=ids) self.assertTrue(matcher.called) self.assertEqual(ids, matcher.last_request.json()['ids']) + + +class TestPluginInstallFacade(TestPluginsFacade): + + def setUp(self): + super(TestPluginInstallFacade, self).setUp() + + self.m_exec = mock.patch.object(fuelclient.utils, 'exec_cmd') + self.m_is_master = mock.patch('fuelclient.objects.plugins.IS_MASTER', + True) + self.m_meta = mock.patch.object(fuelclient.utils, + 'glob_and_parse_yaml', + return_value=self.fake_plugins) + + self.m_file_exists = mock.patch.object(fuelclient.utils, + 'file_exists', + return_value=True) + + def exec_install(self, force=False): + path = '/path/to/plugin.rpm' + + post_matcher = self.m_request.post(self.res_uri, json={}) + get_matcher = self.m_request.get(self.res_uri, json={}) + put_matcher = None + + fake_plugin = self.fake_plugins[0] + if force: + put_uri = '/api/{version}/plugins/{id}'.format( + version=self.version, + id=fake_plugin['id']) + put_matcher = self.m_request.put(put_uri, json={}) + post_matcher = self.m_request.post(self.res_uri, json={ + 'message': json.dumps({'id': fake_plugin['id']}) + }, status_code=409) + m_name = mock.patch.object(fuelclient.objects.plugins.PluginV2, + 'name_from_file', + return_value=fake_plugin['name']) + m_version = mock.patch.object(fuelclient.objects.plugins.PluginV2, + 'version_from_file', + return_value=fake_plugin['version']) + + with m_name, m_version, self.m_is_master, self.m_meta, self.m_exec,\ + self.m_file_exists: + self.client.install(path, force) + + self.assertTrue(get_matcher.called) + self.assertTrue(post_matcher.called) + self.assertEqual(fake_plugin, + json.loads(post_matcher.last_request.body)) + if force: + self.assertTrue(put_matcher.called) + self.assertEqual(fake_plugin, + json.loads(put_matcher.last_request.body)) + + def test_install_plugin(self): + + self.exec_install() + + def test_install_plugin_force(self): + self.exec_install(True) + + def test_install_plugin_fail_not_master(self): + self.m_is_master = mock.patch('fuelclient.objects.plugins.IS_MASTER', + False) + self.assertRaises(error.WrongEnvironmentError, self.exec_install) + + def test_install_plugin_fail_file_not_exists(self): + self.m_file_exists = mock.patch.object(fuelclient.utils, + 'file_exists', + return_value=False) + self.assertRaises(error.BadDataException, self.exec_install) + + +class TestPluginRemoveFacade(TestPluginsFacade): + + def setUp(self): + super(TestPluginRemoveFacade, self).setUp() + + self.m_exec = mock.patch.object(fuelclient.utils, 'exec_cmd') + self.m_is_master = mock.patch('fuelclient.objects.plugins.IS_MASTER', + True) + + def exec_remove(self): + fake_plugin = self.fake_plugins[0] + expected_uri = '/api/{version}/plugins/{id}'.format( + version=self.version, id=fake_plugin['id']) + del_matcher = self.m_request.delete(expected_uri, json={}) + get_matcher = self.m_request.get(self.res_uri, json=self.fake_plugins) + with self.m_is_master, self.m_exec: + self.client.remove(fake_plugin['name'], fake_plugin['version']) + self.assertTrue(get_matcher.called) + self.assertTrue(del_matcher.called) + self.assertIsNone(del_matcher.last_request.body) + + def test_remove_plugin(self): + self.exec_remove() + + def test_remove_plugin_fail_not_master(self): + self.m_is_master = mock.patch('fuelclient.objects.plugins.IS_MASTER', + False) + self.assertRaises(error.WrongEnvironmentError, self.exec_remove) diff --git a/fuelclient/v1/plugins.py b/fuelclient/v1/plugins.py index 930ce70..904ca3d 100644 --- a/fuelclient/v1/plugins.py +++ b/fuelclient/v1/plugins.py @@ -53,6 +53,24 @@ class PluginsClient(base_v1.BaseV1Client): self._entity_wrapper.sync(plugin_ids=ids) + def install(self, plugin_path, force=False): + """Install plugin archive and register in API service. + + :param plugin_path: Path to plugin file + :type plugin_path: str + :param force: Update existent plugin even if it is not updatable + :type force: bool + """ + return self._entity_wrapper.install(plugin_path, force=force) + + def remove(self, plugin_name, plugin_version): + """Remove the plugin package, and update data in API service. + + :param str plugin_name: Name of plugin to remove + :param str plugin_version: Version of plugin to remove + """ + return self._entity_wrapper.remove(plugin_name, plugin_version) + def get_client(connection): return PluginsClient(connection) diff --git a/setup.cfg b/setup.cfg index e80da58..eda59dc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -103,7 +103,9 @@ fuelclient = openstack-config_execute=fuelclient.commands.openstack_config:OpenstackConfigExecute openstack-config_list=fuelclient.commands.openstack_config:OpenstackConfigList openstack-config_upload=fuelclient.commands.openstack_config:OpenstackConfigUpload + plugins_install=fuelclient.commands.plugins:PluginInstall plugins_list=fuelclient.commands.plugins:PluginsList + plugins_remove=fuelclient.commands.plugins:PluginRemove plugins_sync=fuelclient.commands.plugins:PluginsSync release_component_list=fuelclient.commands.release:ReleaseComponentList release_list=fuelclient.commands.release:ReleaseList