diff --git a/lib/ansible/modules/network/f5/bigip_device_trust.py b/lib/ansible/modules/network/f5/bigip_device_trust.py new file mode 100644 index 00000000000..4e7cdf7915f --- /dev/null +++ b/lib/ansible/modules/network/f5/bigip_device_trust.py @@ -0,0 +1,324 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017 F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: bigip_device_trust +short_description: Manage the trust relationships between BIG-IPs +description: + - Manage the trust relationships between BIG-IPs. Devices, once peered, cannot + be updated. If updating is needed, the peer must first be removed before it + can be re-added to the trust. +version_added: "2.5" +options: + peer_server: + description: + - The peer address to connect to and trust for synchronizing configuration. + This is typically the management address of the remote device, but may + also be a Self IP. + required: True + peer_hostname: + description: + - The hostname that you want to associate with the device. This value will + be used to easily distinguish this device in BIG-IP configuration. If not + specified, the value of C(peer_server) will be used as a default. + peer_user: + description: + - The API username of the remote peer device that you are trusting. Note + that the CLI user cannot be used unless it too has an API account. If this + value is not specified, then the value of C(user), or the environment + variable C(F5_USER) will be used. + peer_password: + description: + - The password of the API username of the remote peer device that you are + trusting. If this value is not specified, then the value of C(password), + or the environment variable C(F5_PASSWORD) will be used. + type: + description: + - Specifies whether the device you are adding is a Peer or a Subordinate. + The default is C(peer). + - The difference between the two is a matter of mitigating risk of + compromise. + - A subordinate device cannot sign a certificate for another device. + - In the case where the security of an authority device in a trust domain + is compromised, the risk of compromise is minimized for any subordinate + device. + - Designating devices as subordinate devices is recommended for device + groups with a large number of member devices, where the risk of compromise + is high. + choices: + - peer + - subordinate + default: peer +notes: + - Requires the f5-sdk Python package on the host. This is as easy as + pip install f5-sdk. +requirements: + - f5-sdk + - netaddr +extends_documentation_fragment: f5 +author: + - Tim Rupp (@caphrim007) +''' + +EXAMPLES = r''' +- name: Add trusts for all peer devices to Active device + bigip_device_trust: + server: lb.mydomain.com + user: admin + password: secret + peer_server: "{{ item.ansible_host }}" + peer_hostname: "{{ item.inventory_hostname }}" + peer_user: "{{ item.bigip_username }}" + peer_password: "{{ item.bigip_password }}" + with_items: hostvars + when: inventory_hostname in groups['master'] + delegate_to: localhost +''' + +RETURN = r''' +peer_server: + description: The remote IP address of the trusted peer. + returned: changed + type: string + sample: 10.0.2.15 +peer_hostname: + description: The remote hostname used to identify the trusted peer. + returned: changed + type: string + sample: test-bigip-02.localhost.localdomain +''' + +import re + +try: + import netaddr + HAS_NETADDR = True +except ImportError: + HAS_NETADDR = False + +from ansible.module_utils.f5_utils import AnsibleF5Client +from ansible.module_utils.f5_utils import AnsibleF5Parameters +from ansible.module_utils.f5_utils import HAS_F5SDK +from ansible.module_utils.f5_utils import F5ModuleError + +try: + from ansible.module_utils.f5_utils import iControlUnexpectedHTTPError +except ImportError: + HAS_F5SDK = False + + +class Parameters(AnsibleF5Parameters): + api_map = { + 'deviceName': 'peer_hostname', + 'caDevice': 'type', + 'device': 'peer_server', + 'username': 'peer_user', + 'password': 'peer_password' + } + + api_attributes = [ + 'name', 'caDevice', 'device', 'deviceName', 'username', 'password' + ] + + returnables = [ + 'peer_server', 'peer_hostname' + ] + + updatables = [] + + def to_return(self): + result = {} + try: + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + return result + except Exception: + return result + + def api_params(self): + result = {} + for api_attribute in self.api_attributes: + if self.api_map is not None and api_attribute in self.api_map: + result[api_attribute] = getattr(self, self.api_map[api_attribute]) + else: + result[api_attribute] = getattr(self, api_attribute) + result = self._filter_params(result) + return result + + @property + def peer_server(self): + if self._values['peer_server'] is None: + return None + try: + result = str(netaddr.IPAddress(self._values['peer_server'])) + return result + except netaddr.core.AddrFormatError: + raise F5ModuleError( + "The provided 'peer_server' parameter is not an IP address." + ) + + @property + def peer_hostname(self): + if self._values['peer_hostname'] is None: + return self.peer_server + regex = re.compile('[^a-zA-Z.-_]') + result = regex.sub('_', self._values['peer_hostname']) + return result + + @property + def partition(self): + # Partitions are not supported when making peers. + # Everybody goes in Common. + return None + + @property + def type(self): + if self._values['type'] == 'peer': + return True + return False + + +class ModuleManager(object): + def __init__(self, client): + self.client = client + self.have = None + self.want = Parameters(self.client.module.params) + self.changes = Parameters() + + def _set_changed_options(self): + changed = {} + for key in Parameters.returnables: + if getattr(self.want, key) is not None: + changed[key] = getattr(self.want, key) + if changed: + self.changes = Parameters(changed) + + def exec_module(self): + changed = False + result = dict() + state = self.want.state + + try: + if state == "present": + changed = self.present() + elif state == "absent": + changed = self.absent() + except iControlUnexpectedHTTPError as e: + raise F5ModuleError(str(e)) + + changes = self.changes.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + return result + + def present(self): + if self.exists(): + return False + else: + return self.create() + + def create(self): + self._set_changed_options() + if self.want.peer_user is None: + self.want.update({'peer_user': self.want.user}) + if self.want.peer_password is None: + self.want.update({'peer_password': self.want.password}) + if self.want.peer_hostname is None: + self.want.update({'peer_hostname': self.want.server}) + if self.client.check_mode: + return True + self.create_on_device() + return True + + def absent(self): + if self.exists(): + return self.remove() + return False + + def remove(self): + if self.client.check_mode: + return True + self.remove_from_device() + if self.exists(): + raise F5ModuleError("Failed to remove the trusted peer.") + return True + + def exists(self): + result = self.client.api.tm.cm.devices.get_collection() + for device in result: + try: + if device.managementIp == self.want.peer_server: + return True + except AttributeError: + pass + return False + + def create_on_device(self): + params = self.want.api_params() + self.client.api.tm.cm.add_to_trust.exec_cmd( + 'run', + name='Root', + **params + ) + + def remove_from_device(self): + result = self.client.api.tm.cm.remove_from_trust.exec_cmd( + 'run', deviceName=self.want.peer_hostname + ) + if result: + result.delete() + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + self.argument_spec = dict( + peer_server=dict(required=True), + peer_hostname=dict(), + peer_user=dict(), + peer_password=dict(no_log=True), + type=dict( + choices=['peer', 'subordinate'], + default='peer' + ) + ) + self.f5_product_name = 'bigip' + + +def main(): + try: + spec = ArgumentSpec() + + client = AnsibleF5Client( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + f5_product_name=spec.f5_product_name + ) + + if not HAS_F5SDK: + raise F5ModuleError("The python f5-sdk module is required") + + if not HAS_NETADDR: + raise F5ModuleError("The python netaddr module is required") + + mm = ModuleManager(client) + results = mm.exec_module() + client.module.exit_json(**results) + except F5ModuleError as e: + client.module.fail_json(msg=str(e)) + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/network/f5/test_bigip_device_trust.py b/test/units/modules/network/f5/test_bigip_device_trust.py new file mode 100644 index 00000000000..10d1139e1e1 --- /dev/null +++ b/test/units/modules/network/f5/test_bigip_device_trust.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017 F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import os +import json +import sys + +from nose.plugins.skip import SkipTest +if sys.version_info < (2, 7): + raise SkipTest("F5 Ansible modules require Python >= 2.7") + +from ansible.compat.tests import unittest +from ansible.compat.tests.mock import patch, Mock +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes +from ansible.module_utils.f5_utils import AnsibleF5Client + +try: + from library.bigip_device_trust import Parameters + from library.bigip_device_trust import ModuleManager + from library.bigip_device_trust import ArgumentSpec + from library.bigip_device_trust import HAS_F5SDK + from library.bigip_device_trust import HAS_NETADDR + from ansible.module_utils.f5_utils import iControlUnexpectedHTTPError +except ImportError: + try: + from ansible.modules.network.f5.bigip_device_trust import Parameters + from ansible.modules.network.f5.bigip_device_trust import ModuleManager + from ansible.modules.network.f5.bigip_device_trust import ArgumentSpec + from ansible.modules.network.f5.bigip_device_trust import HAS_F5SDK + from ansible.modules.network.f5.bigip_device_trust import HAS_NETADDR + from ansible.module_utils.f5_utils import iControlUnexpectedHTTPError + except ImportError: + raise SkipTest("F5 Ansible modules require the f5-sdk Python library") + + from ansible.modules.network.f5.bigip_device_trust import HAS_NETADDR + if not HAS_NETADDR: + raise SkipTest("F5 Ansible modules require the netaddr Python library") + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +def set_module_args(args): + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +def load_fixture(name): + path = os.path.join(fixture_path, name) + + if path in fixture_data: + return fixture_data[path] + + with open(path) as f: + data = f.read() + + try: + data = json.loads(data) + except Exception: + pass + + fixture_data[path] = data + return data + + +class TestParameters(unittest.TestCase): + def test_module_parameters(self): + args = dict( + peer_server='10.10.10.10', + peer_hostname='foo.bar.baz', + peer_user='admin', + peer_password='secret' + ) + + p = Parameters(args) + assert p.peer_server == '10.10.10.10' + assert p.peer_hostname == 'foo.bar.baz' + assert p.peer_user == 'admin' + assert p.peer_password == 'secret' + + def test_module_parameters_with_peer_type(self): + args = dict( + peer_server='10.10.10.10', + peer_hostname='foo.bar.baz', + peer_user='admin', + peer_password='secret', + type='peer' + ) + + p = Parameters(args) + assert p.peer_server == '10.10.10.10' + assert p.peer_hostname == 'foo.bar.baz' + assert p.peer_user == 'admin' + assert p.peer_password == 'secret' + assert p.type is True + + def test_module_parameters_with_subordinate_type(self): + args = dict( + peer_server='10.10.10.10', + peer_hostname='foo.bar.baz', + peer_user='admin', + peer_password='secret', + type='subordinate' + ) + + p = Parameters(args) + assert p.peer_server == '10.10.10.10' + assert p.peer_hostname == 'foo.bar.baz' + assert p.peer_user == 'admin' + assert p.peer_password == 'secret' + assert p.type is False + + +@patch('ansible.module_utils.f5_utils.AnsibleF5Client._get_mgmt_root', + return_value=True) +class TestManager(unittest.TestCase): + + def setUp(self): + self.spec = ArgumentSpec() + + def test_create_device_trust(self, *args): + set_module_args(dict( + peer_server='10.10.10.10', + peer_hostname='foo.bar.baz', + peer_user='admin', + peer_password='secret', + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + # Override methods in the specific type of manager + mm = ModuleManager(client) + mm.exists = Mock(side_effect=[False, True]) + mm.create_on_device = Mock(return_value=True) + + results = mm.exec_module() + + assert results['changed'] is True + + def test_create_device_trust_idempotent(self, *args): + set_module_args(dict( + peer_server='10.10.10.10', + peer_hostname='foo.bar.baz', + peer_user='admin', + peer_password='secret', + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + # Override methods in the specific type of manager + mm = ModuleManager(client) + mm.exists = Mock(return_value=True) + + results = mm.exec_module() + + assert results['changed'] is False