diff --git a/fuelclient/cli/actions/__init__.py b/fuelclient/cli/actions/__init__.py index f79b35b3..85382626 100644 --- a/fuelclient/cli/actions/__init__.py +++ b/fuelclient/cli/actions/__init__.py @@ -32,6 +32,7 @@ from fuelclient.cli.actions.node import NodeAction from fuelclient.cli.actions.nodegroup import NodeGroupAction from fuelclient.cli.actions.notifications import NotificationsAction from fuelclient.cli.actions.notifications import NotifyAction +from fuelclient.cli.actions.openstack_config import OpenstackConfigAction from fuelclient.cli.actions.release import ReleaseAction from fuelclient.cli.actions.role import RoleAction from fuelclient.cli.actions.settings import SettingsAction @@ -68,6 +69,7 @@ actions_tuple = ( GraphAction, FuelVersionAction, NetworkGroupAction, + OpenstackConfigAction, ) actions = dict( diff --git a/fuelclient/cli/actions/openstack_config.py b/fuelclient/cli/actions/openstack_config.py new file mode 100644 index 00000000..aa83d59a --- /dev/null +++ b/fuelclient/cli/actions/openstack_config.py @@ -0,0 +1,148 @@ +# Copyright 2015 Mirantis, 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. + +from fuelclient.cli.actions.base import Action +from fuelclient.cli.actions.base import check_all +import fuelclient.cli.arguments as Args +from fuelclient.cli.arguments import group +from fuelclient.cli.formatting import format_table +from fuelclient.objects.openstack_config import OpenstackConfig + + +class OpenstackConfigAction(Action): + """Manage openstack configuration""" + + action_name = 'openstack-config' + acceptable_keys = ('id', 'is_active', 'config_type', + 'cluster_id', 'node_id', 'node_role') + + def __init__(self): + super(OpenstackConfigAction, self).__init__() + self.args = ( + Args.get_env_arg(), + Args.get_file_arg("Openstack configuration file"), + Args.get_single_node_arg("Node ID"), + Args.get_single_role_arg("Node role"), + Args.get_config_id_arg("Openstack config ID"), + Args.get_deleted_arg("Get deleted configurations"), + group( + Args.get_list_arg("List openstack configurations"), + Args.get_download_arg( + "Download current openstack configuration"), + Args.get_upload_arg("Upload new openstack configuration"), + Args.get_delete_arg("Delete openstack configuration"), + Args.get_execute_arg("Apply openstack configuration"), + required=True, + ) + ) + + self.flag_func_map = ( + ('list', self.list), + ('download', self.download), + ('upload', self.upload), + ('delete', self.delete), + ('execute', self.execute) + ) + + def list(self, params): + """List all available configurations: + fuel openstack-config --list --env 1 + fuel openstack-config --list --env 1 --node 1 + fuel openstack-config --list --env 1 --deleted + """ + filters = {} + + if 'env' in params: + filters['cluster_id'] = params.env + + if 'deleted' in params: + filters['is_active'] = int(not params.deleted) + + if 'node' in params: + filters['node_id'] = params.node + + if 'role' in params: + filters['node_role'] = params.role + + configs = OpenstackConfig.get_filtered_data(**filters) + + self.serializer.print_to_output( + configs, + format_table( + configs, + acceptable_keys=self.acceptable_keys + ) + ) + + @check_all('config-id') + def download(self, params): + """Download an existing configuration to file: + fuel openstack-config --download --config-id 1 --file config.yaml + """ + config_id = getattr(params, 'config-id') + config = OpenstackConfig(config_id) + data = config.data + OpenstackConfig.write_file(params.file, { + 'configuration': data['configuration']}) + + @check_all('env') + def upload(self, params): + """Upload new configuration from file: + fuel openstack-config --upload --env 1 --file config.yaml + fuel openstack-config --upload --env 1 --node 1 --file config.yaml + fuel openstack-config --upload --env 1 + --role controller --file config.yaml + """ + node_id = getattr(params, 'node', None) + node_role = getattr(params, 'role', None) + data = OpenstackConfig.read_file(params.file) + + config = OpenstackConfig.create( + cluster_id=params.env, + configuration=data['configuration'], + node_id=node_id, node_role=node_role) + print("Openstack configuration with id {0} " + "has been uploaded from file '{1}'" + "".format(config.id, params.file)) + + @check_all('config-id') + def delete(self, params): + """Delete an existing configuration: + fuel openstack-config --delete --config 1 + """ + config_id = getattr(params, 'config-id') + config = OpenstackConfig(config_id) + config.delete() + print("Openstack configuration '{0}' " + "has been deleted.".format(config_id)) + + @check_all('env') + def execute(self, params): + """Deploy configuration: + fuel openstack-config --execute --env 1 + fuel openstack-config --execute --env 1 --node 1 + fuel openstack-config --execute --env 1 --role controller + """ + node_id = getattr(params, 'node', None) + node_role = getattr(params, 'role', None) + task_result = OpenstackConfig.execute( + cluster_id=params.env, node_id=node_id, + node_role=node_role) + if task_result['status'] == 'error': + print( + 'Error applying openstack configuration: {0}.'.format( + task_result['message']) + ) + else: + print('Openstack configuration update is started.') diff --git a/fuelclient/cli/arguments.py b/fuelclient/cli/arguments.py index df1ffe24..d7b973fb 100644 --- a/fuelclient/cli/arguments.py +++ b/fuelclient/cli/arguments.py @@ -284,6 +284,10 @@ def get_role_arg(help_msg): return get_set_type_arg("role", flags=("-r",), help=help_msg) +def get_single_role_arg(help_msg): + return get_str_arg("role", flags=('--role', ), help=help_msg) + + def get_check_arg(help_msg): return get_set_type_arg("check", help=help_msg) @@ -415,6 +419,10 @@ def get_delete_arg(help_msg): return get_boolean_arg("delete", help=help_msg) +def get_execute_arg(help_msg): + return get_boolean_arg("execute", help=help_msg) + + def get_assign_arg(help_msg): return get_boolean_arg("assign", help=help_msg) @@ -486,6 +494,10 @@ def get_node_arg(help_msg): return get_arg("node", **default_kwargs) +def get_single_node_arg(help_msg): + return get_int_arg('node', flags=('--node-id',), help=help_msg) + + def get_task_arg(help_msg): return get_array_arg( 'task', @@ -494,6 +506,17 @@ def get_task_arg(help_msg): ) +def get_config_id_arg(help_msg): + return get_int_arg( + 'config-id', + help=help_msg) + + +def get_deleted_arg(help_msg): + return get_boolean_arg( + 'deleted', help=help_msg) + + def get_plugin_install_arg(help_msg): return get_str_arg( "install", diff --git a/fuelclient/cli/serializers.py b/fuelclient/cli/serializers.py index 33ad6e8d..fe344519 100644 --- a/fuelclient/cli/serializers.py +++ b/fuelclient/cli/serializers.py @@ -88,14 +88,17 @@ class Serializer(object): def write_to_path(self, path, data): full_path = self.prepare_path(path) + return self.write_to_full_path(full_path, data) + + def write_to_full_path(self, path, data): try: - with open(full_path, "w") as file_to_write: + with open(path, "w") as file_to_write: self.write_to_file(file_to_write, data) except IOError as e: raise error.InvalidFileException( "Can't write to file '{0}': {1}.".format( - full_path, e.strerror)) - return full_path + path, e.strerror)) + return path def read_from_file(self, path): return self.read_from_full_path(self.prepare_path(path)) diff --git a/fuelclient/objects/__init__.py b/fuelclient/objects/__init__.py index 6b3e6c46..41696164 100644 --- a/fuelclient/objects/__init__.py +++ b/fuelclient/objects/__init__.py @@ -20,6 +20,7 @@ from fuelclient.objects.base import BaseObject from fuelclient.objects.environment import Environment from fuelclient.objects.node import Node from fuelclient.objects.node import NodeCollection +from fuelclient.objects.openstack_config import OpenstackConfig from fuelclient.objects.release import Release from fuelclient.objects.task import DeployTask from fuelclient.objects.task import SnapshotTask diff --git a/fuelclient/objects/openstack_config.py b/fuelclient/objects/openstack_config.py new file mode 100644 index 00000000..e1ac3b2c --- /dev/null +++ b/fuelclient/objects/openstack_config.py @@ -0,0 +1,67 @@ +# Copyright 2015 Mirantis, 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 os + +import six + +from fuelclient.cli import error +from fuelclient.cli.serializers import Serializer +from fuelclient.objects.base import BaseObject + + +class OpenstackConfig(BaseObject): + + class_api_path = 'openstack-config/' + instance_api_path = 'openstack-config/{0}/' + execute_api_path = 'openstack-config/execute/' + + @classmethod + def _prepare_params(cls, filters): + return dict((k, v) for k, v in six.iteritems(filters) if v is not None) + + @classmethod + def create(cls, **kwargs): + params = cls._prepare_params(kwargs) + data = cls.connection.post_request(cls.class_api_path, params) + return cls.init_with_data(data) + + def delete(self): + return self.connection.delete_request( + self.instance_api_path.format(self.id)) + + @classmethod + def execute(cls, **kwargs): + params = cls._prepare_params(kwargs) + return cls.connection.put_request(cls.execute_api_path, params) + + @classmethod + def get_filtered_data(cls, **kwargs): + url = cls.class_api_path + params = cls._prepare_params(kwargs) + return cls.connection.get_request(url, params=params) + + @classmethod + def read_file(cls, path): + if not os.path.exists(path): + raise error.InvalidFileException( + "File '{0}' doesn't exist.".format(path)) + + serializer = Serializer() + return serializer.read_from_full_path(path) + + @classmethod + def write_file(cls, path, data): + serializer = Serializer() + return serializer.write_to_full_path(path, data) diff --git a/fuelclient/tests/unit/v1/test_openstack_config.py b/fuelclient/tests/unit/v1/test_openstack_config.py new file mode 100644 index 00000000..9f328fba --- /dev/null +++ b/fuelclient/tests/unit/v1/test_openstack_config.py @@ -0,0 +1,105 @@ +# Copyright 2015 Mirantis, 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 mock +import yaml + +from fuelclient.tests.unit.v1 import base +from fuelclient.tests import utils + + +class TestOpenstackConfigActions(base.UnitTestCase): + + def setUp(self): + super(TestOpenstackConfigActions, self).setUp() + + self.config = utils.get_fake_openstack_config() + + def test_config_download(self): + m_get = self.m_request.get( + '/api/v1/openstack-config/42/', json=self.config) + m_open = mock.mock_open() + with mock.patch('fuelclient.cli.serializers.open', + m_open, create=True): + self.execute(['fuel', 'openstack-config', + '--config-id', '42', '--download', + '--file', 'config.yaml']) + + self.assertTrue(m_get.called) + content = m_open().write.mock_calls[0][1][0] + content = yaml.safe_load(content) + self.assertEqual(self.config['configuration'], + content['configuration']) + + def test_config_upload(self): + m_post = self.m_request.post( + '/api/v1/openstack-config/', json=self.config) + m_open = mock.mock_open(read_data=yaml.safe_dump( + {'configuration': self.config['configuration']})) + with mock.patch('fuelclient.cli.serializers.open', + m_open, create=True): + with mock.patch('fuelclient.objects.openstack_config.os'): + self.execute(['fuel', 'openstack-config', '--env', '1', + '--upload', '--file', 'config.yaml']) + self.assertTrue(m_post.called) + + def test_config_list(self): + m_get = self.m_request.get( + '/api/v1/openstack-config/?cluster_id=84', json=[ + utils.get_fake_openstack_config(id=1, cluster_id=32), + utils.get_fake_openstack_config(id=2, cluster_id=64) + ]) + self.execute(['fuel', 'openstack-config', '--env', '84', '--list']) + self.assertTrue(m_get.called) + + def test_config_list_w_filters(self): + m_get = self.m_request.get( + '/api/v1/openstack-config/?cluster_id=84&node_role=controller', + json=[utils.get_fake_openstack_config(id=1, cluster_id=32)]) + self.execute(['fuel', 'openstack-config', '--env', '84', + '--role', 'controller', '--list']) + self.assertTrue(m_get.called) + + m_get = self.m_request.get( + '/api/v1/openstack-config/?cluster_id=84&node_id=42', json=[ + utils.get_fake_openstack_config(id=1, cluster_id=32), + ]) + self.execute(['fuel', 'openstack-config', '--env', '84', + '--node', '42', '--list']) + self.assertTrue(m_get.called) + + def test_config_delete(self): + m_del = self.m_request.delete( + '/api/v1/openstack-config/42/', json={}) + self.execute(['fuel', 'openstack-config', + '--config-id', '42', '--delete']) + self.assertTrue(m_del.called) + + def test_config_execute(self): + m_put = self.m_request.put('/api/v1/openstack-config/execute/', + json={'status': 'ready'}) + self.execute(['fuel', 'openstack-config', '--env', '42', '--execute']) + self.assertTrue(m_put.called) + + def test_config_execute_fail(self): + message = 'Some error' + m_put = self.m_request.put( + '/api/v1/openstack-config/execute/', + json={'status': 'error', 'message': message}) + + with mock.patch("sys.stdout") as m_stdout: + self.execute(['fuel', 'openstack-config', + '--env', '42', '--execute']) + self.assertTrue(m_put.called) + self.assertIn(message, m_stdout.write.call_args_list[0][0][0]) diff --git a/fuelclient/tests/utils/__init__.py b/fuelclient/tests/utils/__init__.py index a700dddc..94fe926c 100644 --- a/fuelclient/tests/utils/__init__.py +++ b/fuelclient/tests/utils/__init__.py @@ -24,6 +24,8 @@ from fuelclient.tests.utils.fake_fuel_version import get_fake_fuel_version from fuelclient.tests.utils.fake_task import get_fake_task from fuelclient.tests.utils.fake_node_group import get_fake_node_group from fuelclient.tests.utils.fake_node_group import get_fake_node_groups +from fuelclient.tests.utils.fake_openstack_config \ + import get_fake_openstack_config __all__ = (get_fake_env, @@ -35,4 +37,5 @@ __all__ = (get_fake_env, get_fake_task, random_string, get_fake_node_group, - get_fake_node_groups) + get_fake_node_groups, + get_fake_openstack_config) diff --git a/fuelclient/tests/utils/fake_openstack_config.py b/fuelclient/tests/utils/fake_openstack_config.py new file mode 100644 index 00000000..06360b55 --- /dev/null +++ b/fuelclient/tests/utils/fake_openstack_config.py @@ -0,0 +1,39 @@ +# Copyright 2015 Mirantis, 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. + + +def get_fake_openstack_config( + id=None, config_type=None, cluster_id=None, node_id=None, + node_role=None, configuration=None): + config = { + 'id': id or 42, + 'is_active': True, + 'config_type': config_type or 'cluster', + 'cluster_id': cluster_id or 84, + 'node_id': node_id or None, + 'node_role': node_role or None, + 'configuration': configuration or { + 'nova_config': { + 'DEFAULT/debug': { + 'value': True, + }, + }, + 'keystone_config': { + 'DEFAULT/debug': { + 'value': True, + }, + }, + }, + } + return config