diff --git a/os_faults/__init__.py b/os_faults/__init__.py index ee4d33f..c5c0491 100644 --- a/os_faults/__init__.py +++ b/os_faults/__init__.py @@ -10,6 +10,7 @@ # License for the specific language governing permissions and limitations # under the License. +import copy import os import appdirs @@ -119,15 +120,13 @@ CONFIG_SCHEMA = { } -def _read_config(config_filename): - os_faults_config = config_filename or os.environ.get('OS_FAULTS_CONFIG') - if os_faults_config: - CONFIG_FILES.insert(0, os_faults_config) +def get_default_config_file(): + if 'OS_FAULTS_CONFIG' in os.environ: + return os.environ['OS_FAULTS_CONFIG'] for config_file in CONFIG_FILES: if os.path.exists(config_file): - with open(config_file) as fd: - return yaml.safe_load(fd.read()) + return config_file msg = 'Config file is not found on any of paths: {}'.format(CONFIG_FILES) raise error.OSFError(msg) @@ -144,10 +143,12 @@ def connect(cloud_config=None, config_filename=None): :param cloud_config: dict with cloud and power management params :param config_filename: name of the file where to read config from - :return: CloudManagement object + :returns: CloudManagement object """ if cloud_config is None: - cloud_config = _read_config(config_filename) + config_filename = config_filename or get_default_config_file() + with open(config_filename) as fd: + cloud_config = yaml.safe_load(fd.read()) jsonschema.validate(cloud_config, CONFIG_SCHEMA) @@ -184,6 +185,42 @@ def connect(cloud_config=None, config_filename=None): return cloud_management +def discover(cloud_config): + """Connect to the cloud and discover nodes and services + + :param cloud_config: dict with cloud and power management params + :returns: config dict with discovered nodes/services + """ + + cloud_config = copy.deepcopy(cloud_config) + cloud_management = connect(cloud_config) + + # discover nodes + hosts = [] + for host in cloud_management.get_nodes().hosts: + hosts.append({'ip': host.ip, 'mac': host.mac, 'fqdn': host.fqdn}) + LOG.info('Found node: %s' % str(host)) + cloud_config['node_discover'] = {'driver': 'node_list', 'args': hosts} + + # discover services + cloud_config['services'] = {} + for service_name in cloud_management.list_supported_services(): + service = cloud_management.get_service(service_name) + ips = service.get_nodes().get_ips() + cloud_config['services'][service_name] = { + 'driver': service.NAME, + 'args': service.config + } + if ips: + cloud_config['services'][service_name]['hosts'] = ips + LOG.info('Found service "%s" on hosts: %s' % ( + service_name, str(ips))) + else: + LOG.warning('Service "%s" is not found' % service_name) + + return cloud_config + + def human_api(distractor, command): """Execute high-level text command with specified destructor diff --git a/os_faults/cmd/main.py b/os_faults/cmd/main.py new file mode 100644 index 0000000..8150113 --- /dev/null +++ b/os_faults/cmd/main.py @@ -0,0 +1,88 @@ +# 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 logging + +import click +import yaml + +import os_faults +from os_faults import registry + + +READABLE_FILE = click.Path(dir_okay=False, readable=True, exists=True, + resolve_path=True) +WRITABLE_FILE = click.Path(dir_okay=False, writable=True, resolve_path=True) + +config_option = click.option('-c', '--config', type=READABLE_FILE, + help='path to os-faults cloud connection config') + + +def print_version(ctx, param, value): + if not value or ctx.resilient_parsing: + return + click.echo('Version: %s' % os_faults.get_release()) + ctx.exit() + + +@click.group() +@click.option('--debug', '-d', is_flag=True, help='Enable debug logs') +@click.option('--version', is_flag=True, callback=print_version, + expose_value=False, is_eager=True, help='Show version and exit.') +def main(debug): + logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', + level=logging.DEBUG if debug else logging.INFO) + + +@main.command() +@config_option +def verify(config): + """Verify connection to the cloud""" + config = config or os_faults.get_default_config_file() + destructor = os_faults.connect(config_filename=config) + destructor.verify() + + +@main.command() +@click.argument('output', type=WRITABLE_FILE) +@config_option +def discover(config, output): + """Discover services/nodes and save them to output config file""" + config = config or os_faults.get_default_config_file() + with open(config) as f: + cloud_config = yaml.safe_load(f.read()) + discovered_config = os_faults.discover(cloud_config) + with open(output, 'w') as f: + f.write(yaml.safe_dump(discovered_config, default_flow_style=False)) + click.echo('Saved {}'.format(output)) + + +@main.command() +@config_option +def nodes(config): + """List cloud nodes""" + config = config or os_faults.get_default_config_file() + destructor = os_faults.connect(config_filename=config) + hosts = [{'ip': host.ip, 'mac': host.mac, 'fqdn': host.fqdn} + for host in destructor.get_nodes().hosts] + click.echo(yaml.safe_dump(hosts, default_flow_style=False), nl=False) + + +@main.command() +def drivers(): + """List os-faults drivers""" + drivers = sorted(registry.get_drivers().keys()) + click.echo(yaml.safe_dump(drivers, default_flow_style=False), nl=False) + + +if __name__ == '__main__': + main() diff --git a/os_faults/tests/unit/cmd/test_main.py b/os_faults/tests/unit/cmd/test_main.py new file mode 100644 index 0000000..50bba4d --- /dev/null +++ b/os_faults/tests/unit/cmd/test_main.py @@ -0,0 +1,109 @@ +# 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 + +from click import testing +import mock + +from os_faults.api import cloud_management +from os_faults.api import node_collection +from os_faults.cmd import main +from os_faults.tests.unit import test + + +class MainTestCase(test.TestCase): + + def setUp(self): + super(MainTestCase, self).setUp() + self.runner = testing.CliRunner() + + def test_version(self): + result = self.runner.invoke(main.main, ['--version']) + self.assertEqual(0, result.exit_code) + self.assertIn('Version', result.output) + + @mock.patch('os_faults.connect') + def test_verify(self, mock_connect): + with self.runner.isolated_filesystem(): + with open('my.yaml', 'w') as f: + f.write('foo') + result = self.runner.invoke(main.main, ['verify'], + env={'OS_FAULTS_CONFIG': 'my.yaml'}) + self.assertEqual(0, result.exit_code) + mock_connect.assert_called_once_with(config_filename='my.yaml') + destructor = mock_connect.return_value + destructor.verify.assert_called_once_with() + + @mock.patch('os_faults.connect') + def test_verify_with_config(self, mock_connect): + with self.runner.isolated_filesystem(): + with open('my.yaml', 'w') as f: + f.write('foo') + myconf = os.path.abspath(f.name) + result = self.runner.invoke(main.main, ['verify', '-c', myconf]) + self.assertEqual(0, result.exit_code) + mock_connect.assert_called_once_with(config_filename=myconf) + destructor = mock_connect.return_value + destructor.verify.assert_called_once_with() + + @mock.patch('os_faults.discover') + def test_discover(self, mock_discover): + mock_discover.return_value = {'foo': 'bar'} + with self.runner.isolated_filesystem(): + with open('my.yaml', 'w') as f: + f.write('foo') + myconf = os.path.abspath(f.name) + result = self.runner.invoke(main.main, ['discover', '-c', myconf, + 'my-new.yaml']) + self.assertEqual(0, result.exit_code) + mock_discover.assert_called_once_with('foo') + + with open('my-new.yaml') as f: + self.assertEqual('foo: bar\n', f.read()) + + @mock.patch('os_faults.connect') + def test_nodes(self, mock_connect): + cloud_management_mock = mock.create_autospec( + cloud_management.CloudManagement) + mock_connect.return_value = cloud_management_mock + cloud_management_mock.get_nodes.return_value.hosts = [ + node_collection.Host( + ip='10.0.0.2', mac='09:7b:74:90:63:c1', fqdn='node1.local'), + node_collection.Host( + ip='10.0.0.3', mac='09:7b:74:90:63:c2', fqdn='node2.local')] + + with self.runner.isolated_filesystem(): + with open('my.yaml', 'w') as f: + f.write('foo') + myconf = os.path.abspath(f.name) + result = self.runner.invoke(main.main, ['nodes', '-c', myconf]) + self.assertEqual(0, result.exit_code) + self.assertEqual( + '- fqdn: node1.local\n' + ' ip: 10.0.0.2\n' + ' mac: 09:7b:74:90:63:c1\n' + '- fqdn: node2.local\n' + ' ip: 10.0.0.3\n' + ' mac: 09:7b:74:90:63:c2\n', result.output) + + @mock.patch('os_faults.registry.get_drivers') + def test_drivers(self, mock_get_drivers): + mock_get_drivers.return_value = {'foo': 1, 'bar': 2} + + with self.runner.isolated_filesystem(): + result = self.runner.invoke(main.main, ['drivers']) + self.assertEqual(0, result.exit_code) + self.assertEqual( + '- bar\n' + '- foo\n', result.output) diff --git a/os_faults/tests/unit/test_os_faults.py b/os_faults/tests/unit/test_os_faults.py index 0386bf4..0adfd26 100644 --- a/os_faults/tests/unit/test_os_faults.py +++ b/os_faults/tests/unit/test_os_faults.py @@ -18,7 +18,10 @@ import yaml import os_faults from os_faults.ansible import executor +from os_faults.api import cloud_management from os_faults.api import error +from os_faults.api import node_collection +from os_faults.api import service from os_faults.drivers import devstack from os_faults.drivers import fuel from os_faults.drivers import ipmi @@ -219,3 +222,77 @@ class OSFaultsTestCase(test.TestCase): self.assertRaises(error.OSFError, os_faults.register_ansible_modules, ['/my/bad/path/']) + + @mock.patch('os_faults.connect') + def test_discover(self, mock_connect): + cloud_config = { + 'cloud_management': { + 'driver': 'devstack', + 'args': { + 'address': 'devstack.local', + 'username': 'developer', + 'private_key_file': '/my/path/pk.key', + } + } + } + cloud_management_mock = mock.create_autospec( + cloud_management.CloudManagement) + mock_connect.return_value = cloud_management_mock + cloud_management_mock.get_nodes.return_value.hosts = [ + node_collection.Host( + ip='10.0.0.2', mac='09:7b:74:90:63:c1', fqdn='node1.local'), + node_collection.Host( + ip='10.0.0.3', mac='09:7b:74:90:63:c2', fqdn='node2.local')] + cloud_management_mock.list_supported_services.return_value = [ + 'srv1', 'srv2'] + + def mock_service(name, config, ips): + m = mock.create_autospec(service.Service) + m.NAME = name + m.config = config + m.get_nodes.return_value.get_ips.return_value = ips + return m + + srv1 = mock_service('process', {'grep': 'srv1'}, []) + srv2 = mock_service('linux_service', + {'grep': 'srv2', 'linux_service': 'srv2'}, + ['10.0.0.2']) + services = {'srv1': srv1, 'srv2': srv2} + cloud_management_mock.get_service.side_effect = services.get + + discovered_config = os_faults.discover(cloud_config) + self.assertEqual({ + 'cloud_management': { + 'driver': 'devstack', + 'args': { + 'address': 'devstack.local', + 'private_key_file': '/my/path/pk.key', + 'username': 'developer' + } + }, + 'node_discover': { + 'driver': 'node_list', + 'args': [ + { + 'fqdn': 'node1.local', + 'ip': '10.0.0.2', + 'mac': '09:7b:74:90:63:c1' + }, { + 'fqdn': 'node2.local', + 'ip': '10.0.0.3', + 'mac': '09:7b:74:90:63:c2' + } + ] + }, + 'services': { + 'srv1': { + 'driver': 'process', + 'args': {'grep': 'srv1'}, + }, + 'srv2': { + 'driver': 'linux_service', + 'args': {'grep': 'srv2', 'linux_service': 'srv2'}, + 'hosts': ['10.0.0.2'] + } + } + }, discovered_config) diff --git a/requirements.txt b/requirements.txt index c709088..4eabf94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,8 +6,9 @@ pbr>=2.0.0 # Apache-2.0 ansible>=2.2 appdirs>=1.3.0 # MIT License -jsonschema!=2.5.0,<3.0.0,>=2.0.0 # MIT +click>=6.7 # BSD iso8601>=0.1.11 # MIT +jsonschema!=2.5.0,<3.0.0,>=2.0.0 # MIT oslo.i18n>=2.1.0 # Apache-2.0 oslo.serialization>=1.10.0 # Apache-2.0 oslo.utils>=3.20.0 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index 92494e0..94bdf03 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,6 +23,7 @@ packages = [entry_points] console_scripts = os-inject-fault = os_faults.cmd.cmd:main + os-faults = os_faults.cmd.main:main [extras] libvirt =