diff --git a/os_faults/__init__.py b/os_faults/__init__.py index c5c0491..8e7fdbe 100644 --- a/os_faults/__init__.py +++ b/os_faults/__init__.py @@ -90,7 +90,7 @@ CONFIG_SCHEMA = { 'driver': {'type': 'string'}, 'args': {'type': 'object'}, }, - 'required': ['driver', 'args'], + 'required': ['driver'], 'additionalProperties': False, }, 'power_management': { @@ -134,8 +134,11 @@ def get_default_config_file(): def _init_driver(params): driver_cls = registry.get_driver(params['driver']) - jsonschema.validate(params['args'], driver_cls.CONFIG_SCHEMA) - return driver_cls(params['args']) + + args = params.get('args') or {} # driver may have no arguments + if args: + jsonschema.validate(args, driver_cls.CONFIG_SCHEMA) + return driver_cls(args) def connect(cloud_config=None, config_filename=None): diff --git a/os_faults/drivers/cloud/universal.py b/os_faults/drivers/cloud/universal.py new file mode 100644 index 0000000..eaa1507 --- /dev/null +++ b/os_faults/drivers/cloud/universal.py @@ -0,0 +1,184 @@ +# 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 + +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 node_discover +from os_faults.drivers import shared_schemas + +LOG = logging.getLogger(__name__) + + +class UniversalCloudManagement(cloud_management.CloudManagement, + node_discover.NodeDiscover): + """Universal cloud management driver + + This driver is suitable for the most abstract (and thus universal) case. + The driver does not have any built-in services, all services need + to be listed explicitly in a config file. + + By default the Universal driver works with only one node. To specify + more nodes use `node_list` node discovery driver. Authentication + parameters can be shared or overridden by corresponding parameters + from node discovery. + + **Example single node configuration:** + + .. code-block:: yaml + + cloud_management: + driver: universal + args: + address: 192.168.1.10 + auth: + username: ubuntu + private_key_file: devstack_key + become: true + become_password: my_secret_password + iface: eth1 + serial: 10 + + **Example multi-node configuration:** + + Note that in this configuration a node discovery driver is required. + + .. code-block:: yaml + + cloud_management: + driver: universal + + node_discovery: + driver: node_list + args: + - ip: 192.168.5.149 + auth: + username: developer + private_key_file: cloud_key + become: true + become_password: my_secret_password + + parameters: + + - **address** - address of the node (optional, but if not set + a node discovery driver is mandatory) + - **auth** - SSH related parameters (optional): + - **username** - SSH username (optional) + - **password** - SSH password (optional) + - **private_key_file** - SSH key file (optional) + - **become** - True if privilege escalation is used (optional) + - **become_password** - privilege escalation password (optional) + - **jump** - SSH proxy parameters (optional): + - **host** - SSH proxy host + - **username** - SSH proxy user + - **private_key_file** - SSH proxy key file (optional) + - **iface** - network interface name to retrieve mac address (optional) + - **serial** - how many hosts Ansible should manage at a single time + (optional) default: 10 + """ + + NAME = 'universal' + DESCRIPTION = 'Universal cloud management driver' + CONFIG_SCHEMA = { + 'type': 'object', + '$schema': 'http://json-schema.org/draft-04/schema#', + 'properties': { + 'address': {'type': 'string'}, + 'auth': shared_schemas.AUTH_SCHEMA, + 'iface': {'type': 'string'}, + 'serial': {'type': 'integer', 'minimum': 1}, + }, + 'additionalProperties': False, + } + + def __init__(self, cloud_management_params): + super(UniversalCloudManagement, self).__init__() + self.node_discover = self # by default can discover itself + + self.address = cloud_management_params.get('address') + self.iface = cloud_management_params.get('iface') + serial = cloud_management_params.get('serial') + + auth = cloud_management_params.get('auth') or {} + jump = auth.get('jump') or {} + + self.cloud_executor = executor.AnsibleRunner( + remote_user=auth.get('username'), + password=auth.get('password'), + private_key_file=auth.get('private_key_file'), + become=auth.get('become'), + become_password=auth.get('become_password'), + jump_host=jump.get('host'), + jump_user=jump.get('user'), + serial=serial, + ) + + self.cached_hosts = None # cache for node discovery + + def verify(self): + """Verify connection to the cloud.""" + nodes = self.get_nodes() + if not nodes: + raise error.OSFError('Cloud has no nodes') + + task = {'command': 'hostname'} + task_result = self.execute_on_cloud(nodes.hosts, task) + LOG.debug('Host names of cloud nodes: %s', + ', '.join(r.payload['stdout'] for r in task_result)) + + LOG.info('Connected to cloud successfully!') + + def execute_on_cloud(self, hosts, task, raise_on_error=True): + """Execute task on specified hosts within the cloud. + + :param hosts: List of host FQDNs + :param task: Ansible task + :param raise_on_error: throw exception in case of error + :return: Ansible execution result (list of records) + """ + if raise_on_error: + return self.cloud_executor.execute(hosts, task) + else: + return self.cloud_executor.execute(hosts, task, []) + + def discover_hosts(self): + # this function is called when no node-discovery driver is specified; + # discover the default host set in config for this driver + + if not self.address: + raise error.OSFError('Cloud has no nodes. Specify address in ' + 'cloud management driver or add node ' + 'discovery driver') + + if not self.cached_hosts: + LOG.info('Discovering host name and MAC address for %s', + self.address) + host = node_collection.Host(ip=self.address) + + mac = None + if self.iface: + cmd = 'cat /sys/class/net/{}/address'.format(self.iface) + res = self.execute_on_cloud([host], {'command': cmd}) + mac = res[0].payload['stdout'] + + res = self.execute_on_cloud([host], {'command': 'hostname'}) + hostname = res[0].payload['stdout'] + + # update my hosts + self.cached_hosts = [node_collection.Host( + ip=self.address, mac=mac, fqdn=hostname)] + + return self.cached_hosts diff --git a/os_faults/drivers/shared_schemas.py b/os_faults/drivers/shared_schemas.py index 0460f64..f6594c1 100644 --- a/os_faults/drivers/shared_schemas.py +++ b/os_faults/drivers/shared_schemas.py @@ -21,3 +21,26 @@ PORT_SCHEMA = { 'minItems': 2, 'maxItems': 2, } + +AUTH_SCHEMA = { + 'type': 'object', + 'properties': { + 'username': {'type': 'string'}, + 'password': {'type': 'string'}, + 'sudo': {'type': 'boolean'}, # deprecated, use `become` + 'private_key_file': {'type': 'string'}, + 'become': {'type': 'boolean'}, + 'become_password': {'type': 'string'}, + 'jump': { + 'type': 'object', + 'properties': { + 'host': {'type': 'string'}, + 'username': {'type': 'string'}, + 'private_key_file': {'type': 'string'}, + }, + 'required': ['host'], + 'additionalProperties': False, + }, + }, + 'additionalProperties': False, +} diff --git a/os_faults/tests/unit/drivers/cloud/test_universal.py b/os_faults/tests/unit/drivers/cloud/test_universal.py new file mode 100644 index 0000000..6705d88 --- /dev/null +++ b/os_faults/tests/unit/drivers/cloud/test_universal.py @@ -0,0 +1,105 @@ +# 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 ddt +import mock + +from os_faults.api import node_collection +from os_faults.drivers.cloud import universal +from os_faults.tests.unit import fakes +from os_faults.tests.unit import test + + +@ddt.ddt +class UniversalManagementTestCase(test.TestCase): + + @mock.patch('os_faults.ansible.executor.AnsibleRunner', autospec=True) + @ddt.data(( + dict(address='os.local', auth=dict(username='root')), + dict(remote_user='root', private_key_file=None, password=None, + become=None, become_password=None, jump_host=None, + jump_user=None, serial=None), + ), ( + dict(address='os.local', auth=dict(username='user', become=True, + become_password='secret'), serial=42), + dict(remote_user='user', private_key_file=None, password=None, + become=True, become_password='secret', jump_host=None, + jump_user=None, serial=42), + )) + @ddt.unpack + def test_init(self, config, expected_runner_call, mock_ansible_runner): + ansible_runner_inst = mock_ansible_runner.return_value + + cloud = universal.UniversalCloudManagement(config) + + mock_ansible_runner.assert_called_with(**expected_runner_call) + self.assertIs(cloud.cloud_executor, ansible_runner_inst) + + @mock.patch('os_faults.ansible.executor.AnsibleRunner', autospec=True) + @mock.patch('os_faults.drivers.cloud.universal.UniversalCloudManagement.' + 'discover_hosts') + def test_verify(self, mock_discover_hosts, mock_ansible_runner): + address = '10.0.0.10' + ansible_result = fakes.FakeAnsibleResult( + payload=dict(stdout='openstack.local')) + ansible_runner_inst = mock_ansible_runner.return_value + ansible_runner_inst.execute.side_effect = [ + [ansible_result] + ] + hosts = [node_collection.Host(ip=address)] + mock_discover_hosts.return_value = hosts + + cloud = universal.UniversalCloudManagement(dict(address=address)) + cloud.verify() + + ansible_runner_inst.execute.assert_has_calls([ + mock.call(hosts, {'command': 'hostname'}), + ]) + + @mock.patch('os_faults.ansible.executor.AnsibleRunner', autospec=True) + def test_discover_hosts(self, mock_ansible_runner): + address = '10.0.0.10' + hostname = 'openstack.local' + + ansible_runner_inst = mock_ansible_runner.return_value + ansible_runner_inst.execute.side_effect = [ + [fakes.FakeAnsibleResult( + payload=dict(stdout=hostname))] + ] + expected_hosts = [node_collection.Host( + ip=address, mac=None, fqdn=hostname)] + + cloud = universal.UniversalCloudManagement(dict(address=address)) + + self.assertEqual(expected_hosts, cloud.discover_hosts()) + + @mock.patch('os_faults.ansible.executor.AnsibleRunner', autospec=True) + def test_discover_hosts_with_iface(self, mock_ansible_runner): + address = '10.0.0.10' + hostname = 'openstack.local' + mac = '0b:fe:fe:13:12:11' + + ansible_runner_inst = mock_ansible_runner.return_value + ansible_runner_inst.execute.side_effect = [ + [fakes.FakeAnsibleResult( + payload=dict(stdout=mac))], + [fakes.FakeAnsibleResult( + payload=dict(stdout=hostname))], + ] + expected_hosts = [node_collection.Host( + ip=address, mac=mac, fqdn=hostname)] + + cloud = universal.UniversalCloudManagement( + dict(address=address, iface='eth1')) + + self.assertEqual(expected_hosts, cloud.discover_hosts())