[CLI] Add os-faults discover command

`os-faults discover` discovers nodes and services
and then adds them to output config file.

* Added new CLI
* Added commands:
  os-faults --version
  os-faults verify
  os-faults discover
  os-faults nodes
  os-faults drivers

Change-Id: Id9f12c960fbba4d64ea181ca7245c89b3b4c655d
This commit is contained in:
Anton Studenov 2017-04-25 11:08:27 +03:00
parent 70ab8aa8be
commit 746fc45455
6 changed files with 322 additions and 9 deletions

View File

@ -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

88
os_faults/cmd/main.py Normal file
View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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 =