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