From c5fa7181745bebce9e8870bfb9f496a2209fc1c5 Mon Sep 17 00:00:00 2001 From: Tim Rupp Date: Thu, 10 May 2018 21:44:59 -0700 Subject: [PATCH] Adds bigip_device_license module (#39985) This module can be used to license and unlicense BIGIP devices. This does not include licensing them via BIGIQ. To do that, use BIGIQ modules --- .../network/f5/bigip_device_license.py | 677 ++++++++++++++++++ .../network/f5/test_bigip_device_license.py | 116 +++ 2 files changed, 793 insertions(+) create mode 100644 lib/ansible/modules/network/f5/bigip_device_license.py create mode 100644 test/units/modules/network/f5/test_bigip_device_license.py diff --git a/lib/ansible/modules/network/f5/bigip_device_license.py b/lib/ansible/modules/network/f5/bigip_device_license.py new file mode 100644 index 00000000000..2b6e36b1870 --- /dev/null +++ b/lib/ansible/modules/network/f5/bigip_device_license.py @@ -0,0 +1,677 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2016 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 = ''' +--- +module: bigip_device_license +short_description: Manage license installation and activation on BIG-IP devices +description: + - Manage license installation and activation on a BIG-IP. +version_added: 2.6 +options: + license_key: + description: + - The registration key to use to license the BIG-IP. + - This parameter is required if the C(state) is equal to C(present). + - This parameter is not required when C(state) is C(absent) and will be + ignored if it is provided. + license_server: + description: + - The F5 license server to use when getting a license and validating a dossier. + - This parameter is required if the C(state) is equal to C(present). + - This parameter is not required when C(state) is C(absent) and will be + ignored if it is provided. + default: activate.f5.com + state: + description: + - The state of the license on the system. + - When C(present), only guarantees that a license is there. + - When C(latest), ensures that the license is always valid. + - When C(absent), removes the license on the system. + default: present + choices: + - absent + - present + accept_eula: + description: + - Declares whether you accept the BIG-IP EULA or not. By default, this + value is C(no). You must specifically declare that you have viewed and + accepted the license. This module will not present you with that EULA + though, so it is incumbent on you to read it. + - The EULA can be found here; https://support.f5.com/csp/article/K12902. + - This parameter is not required when C(state) is C(absent) and will be + ignored if it is provided. + type: bool +extends_documentation_fragment: f5 +author: + - Tim Rupp (@caphrim007) +''' + +EXAMPLES = ''' +- name: License BIG-IP using a key + bigip_device_license: + server: "lb.mydomain.com" + user: "admin" + password: "secret" + license_key: "XXXXX-XXXXX-XXXXX-XXXXX-XXXXXXX" + delegate_to: localhost + +- name: Remove the license from the system + bigip_device_license: + server: "lb.mydomain.com" + user: "admin" + password: "secret" + state: "absent" + delegate_to: localhost +''' + +RETURN = r''' +# only common fields returned +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import open_url +from ansible.module_utils.six import iteritems + +import re +import sys +import time +import xml.etree.ElementTree + +try: + from library.module_utils.network.f5.bigip import HAS_F5SDK + from library.module_utils.network.f5.bigip import F5Client + from library.module_utils.network.f5.common import F5ModuleError + from library.module_utils.network.f5.common import AnsibleF5Parameters + from library.module_utils.network.f5.common import cleanup_tokens + from library.module_utils.network.f5.common import fq_name + from library.module_utils.network.f5.common import f5_argument_spec + from library.module_utils.network.f5.icontrol import iControlRestSession + from library.module_utils.network.f5.icontrol import Request + from library.module_utils.network.f5.icontrol import Response + try: + from library.module_utils.network.f5.common import iControlUnexpectedHTTPError + from f5.sdk_exception import UtilError + except ImportError: + HAS_F5SDK = False +except ImportError: + from ansible.module_utils.network.f5.bigip import HAS_F5SDK + from ansible.module_utils.network.f5.bigip import F5Client + from ansible.module_utils.network.f5.common import F5ModuleError + from ansible.module_utils.network.f5.common import AnsibleF5Parameters + from ansible.module_utils.network.f5.common import cleanup_tokens + from ansible.module_utils.network.f5.common import fq_name + from ansible.module_utils.network.f5.common import f5_argument_spec + from ansible.module_utils.network.f5.icontrol import iControlRestSession + from ansible.module_utils.network.f5.icontrol import Request + from ansible.module_utils.network.f5.icontrol import Response + try: + from ansible.module_utils.network.f5.common import iControlUnexpectedHTTPError + from f5.sdk_exception import UtilError + except ImportError: + HAS_F5SDK = False + + +class LicenseXmlParser(object): + def __init__(self, content=None): + self.raw_content = content + try: + self.content = xml.etree.ElementTree.fromstring(content) + except xml.etree.ElementTree.ParseError as ex: + raise F5ModuleError("Provided XML payload is invalid. Received '{0}'.".format(str(ex))) + + @property + def namespaces(self): + result = { + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance' + } + return result + + @property + def eula(self): + try: + root = self.content.findall('.//eula', self.namespaces) + return root[0].text + except Exception: + return None + + @property + def license(self): + try: + root = self.content.findall('.//license', self.namespaces) + return root[0].text + except Exception: + return None + + def find_element(self, value): + root = self.content.findall('.//multiRef', self.namespaces) + if len(root) == 0: + return None + for elem in root: + for k, v in iteritems(elem.attrib): + if value in v: + return elem + + @property + def state(self): + elem = self.find_element('TransactionState') + if elem is not None: + return elem.text + + @property + def fault_number(self): + fault = self.get_fault() + return fault.get('faultNumber', None) + + @property + def fault_text(self): + fault = self.get_fault() + return fault.get('faultText', None) + + def get_fault(self): + result = dict() + + root = self.find_element('LicensingFault') + if root is None: + return result + + for elem in root: + if elem.tag == 'faultNumber': + result['faultNumber'] = int(elem.text) + elif elem.tag == 'faultText': + tmp = elem.attrib.get('{http://www.w3.org/2001/XMLSchema-instance}nil', None) + if tmp == 'true': + result['faultText'] = None + else: + result['faultText'] = elem.text + if 'faultNumber' not in result: + result['faultNumber'] = None + return result + + def json(self): + result = dict( + eula=self.eula or None, + license=self.license or None, + state=self.state or None, + fault_number=self.fault_number, + fault_text=self.fault_text or None + ) + return result + + +class Parameters(AnsibleF5Parameters): + api_map = { + 'licenseEndDateTime': 'license_end_date_time' + } + + api_attributes = [ + + ] + + returnables = [ + + ] + + updatables = [ + + ] + + +class ApiParameters(Parameters): + pass + + +class ModuleParameters(Parameters): + @property + def license_options(self): + result = dict( + eula=self.eula or '', + email=self.email or '', + first_name=self.first_name or '', + last_name=self.last_name or '', + company=self.company or '', + phone=self.phone or '', + job_title=self.job_title or '', + address=self.address or '', + city=self.city or '', + state=self.state or '', + postal_code=self.postal_code or '', + country=self.country or '' + ) + return result + + @property + def license_url(self): + result = 'https://{0}/license/services/urn:com.f5.license.v5b.ActivationService'.format(self.license_server) + return result + + @property + def license_envelope(self): + result = """ + + + + + {1} + {eula} + {email} + {first_name} + {last_name} + {company} + {phone} + {job_title} +
{address}
+ {city} + {state} + {postal_code} + {country} +
+
+
""" + result = result.format(self.license_server, self.dossier, **self.license_options) + return result + + +class Changes(Parameters): + def to_return(self): + result = {} + try: + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + except Exception: + pass + return result + + +class UsableChanges(Changes): + pass + + +class ReportableChanges(Changes): + pass + + +class Difference(object): + def __init__(self, want, have=None): + self.want = want + self.have = have + + def compare(self, param): + try: + result = getattr(self, param) + return result + except AttributeError: + return self.__default(param) + + def __default(self, param): + attr1 = getattr(self.want, param) + try: + attr2 = getattr(self.have, param) + if attr1 != attr2: + return attr1 + except AttributeError: + return attr1 + + +class ModuleManager(object): + def __init__(self, *args, **kwargs): + self.module = kwargs.get('module', None) + self.client = kwargs.get('client', None) + self.want = ModuleParameters(params=self.module.params, client=self.client) + self.have = ApiParameters(client=self.client) + self.changes = UsableChanges() + self.escape_patterns = r'([$"' + "'])" + + 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 = UsableChanges(params=changed) + + def _update_changed_options(self): + diff = Difference(self.want, self.have) + updatables = Parameters.updatables + changed = dict() + for k in updatables: + change = diff.compare(k) + if change is None: + continue + else: + if isinstance(change, dict): + changed.update(change) + else: + changed[k] = change + if changed: + self.changes = UsableChanges(params=changed) + return True + return False + + def should_update(self): + result = self._update_changed_options() + if result: + return True + return False + + 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)) + + reportable = ReportableChanges(params=self.changes.to_return()) + changes = reportable.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + self._announce_deprecations(result) + return result + + def _announce_deprecations(self, result): + warnings = result.pop('__warnings', []) + for warning in warnings: + self.client.module.deprecate( + msg=warning['msg'], + version=warning['version'] + ) + + def present(self): + if self.exists(): + return False + else: + return self.create() + + def remove(self): + if self.module.check_mode: + return True + self.remove_from_device() + if self.exists(): + raise F5ModuleError("Failed to delete the resource.") + return True + + def read_dossier_from_device(self): + result = self.client.api.tm.util.get_dossier.exec_cmd( + 'run', utilCmdArgs='-b {0}'.format(self.want.license_key) + ) + try: + return result.commandResult + except Exception: + return None + + def generate_license_from_remote(self): + mgmt = iControlRestSession() + mgmt.verify = False + mgmt.headers = { + 'SOAPAction': '""', + 'Content-Type': 'text/xml; charset=utf-8', + } + + for x in range(0, 10): + try: + resp = mgmt.post(self.want.license_url, data=self.want.license_envelope) + except Exception as ex: + continue + + try: + resp = LicenseXmlParser(content=resp._content) + result = resp.json() + except F5ModuleError as ex: + # This error occurs when there is a problem with the license server and it + # starts returning invalid XML (like if they upgraded something and the server + # is redirecting improperly. + # + # There's no way to recover from this error except by notifying F5 that there + # is an issue with the license server. + raise + except Exception as ex: + continue + + if result['state'] == 'EULA_REQUIRED': + self.want.update({'eula': result['eula']}) + continue + if result['state'] == 'LICENSE_RETURNED': + return result + elif result['state'] == 'EMAIL_REQUIRED': + raise F5ModuleError("Email must be provided") + elif result['state'] == 'CONTACT_INFO_REQUIRED': + raise F5ModuleError("Contact info must be provided") + else: + raise F5ModuleError(result['fault_text']) + + def create(self): + self._set_changed_options() + if not self.want.accept_eula: + raise F5ModuleError( + "You must read and accept the product EULA to license the box." + ) + if self.module.check_mode: + return True + + dossier = self.read_dossier_from_device() + if dossier: + self.want.update({'dossier': dossier}) + else: + raise F5ModuleError("Dossier not generated.") + + self.create_on_device() + self.wait_for_mcpd() + if not self.exists(): + raise F5ModuleError( + "Failed to license the device." + ) + return True + + def absent(self): + if self.any_license_exists(): + self.remove() + self.wait_for_mcpd() + if self.exists(): + raise F5ModuleError( + "Failed to remove the license from the device." + ) + return True + return False + + def exists(self): + resource = self.client.api.tm.shared.licensing.registration.load() + try: + if resource.registrationKey == self.want.license_key: + return True + except Exception: + pass + return False + + def wait_for_mcpd(self): + nops = 0 + + # Sleep a little to let mcpd settle and begin properly + time.sleep(5) + + while nops < 4: + try: + if self._is_mcpd_ready_on_device(): + nops += 1 + else: + nops = 0 + except Exception as ex: + pass + time.sleep(5) + + def _is_mcpd_ready_on_device(self): + try: + output = self.client.api.tm.util.bash.exec_cmd( + 'run', + utilCmdArgs='-c "tmsh show sys mcp-state | grep running"' + ) + if hasattr(output, 'commandResult'): + return True + except Exception as ex: + pass + return False + + def any_license_exists(self): + resource = self.client.api.tm.shared.licensing.registration.load() + try: + if resource.registrationKey is not None: + return True + except Exception: + pass + return False + + def create_on_device(self): + license = self.generate_license_from_remote() + if license is None: + raise F5ModuleError( + "Failed to generate license from F5 activation servers." + ) + result = self.upload_license_to_device(license) + if not result: + raise F5ModuleError( + "Failed to install license on device." + ) + result = self.upload_eula_to_device(license) + if not result: + raise F5ModuleError( + "Failed to upload EULA file to device." + ) + result = self.reload_license() + if not result: + raise F5ModuleError( + "Failed to reload license configuration." + ) + + def upload_license_to_device(self, license): + license_payload = re.sub(self.escape_patterns, r'\\\1', license['license']) + command = """cat > /config/bigip.license < /LICENSE.F5 <= 2.7") + +from ansible.compat.tests import unittest +from ansible.compat.tests.mock import Mock +from ansible.compat.tests.mock import patch +from ansible.module_utils.basic import AnsibleModule + +try: + from library.modules.bigip_device_license import ApiParameters + from library.modules.bigip_device_license import ModuleParameters + from library.modules.bigip_device_license import ModuleManager + from library.modules.bigip_device_license import ArgumentSpec + from library.module_utils.network.f5.common import F5ModuleError + from library.module_utils.network.f5.common import iControlUnexpectedHTTPError + from test.unit.modules.utils import set_module_args +except ImportError: + try: + from ansible.modules.network.f5.bigip_device_license import ApiParameters + from ansible.modules.network.f5.bigip_device_license import ModuleParameters + from ansible.modules.network.f5.bigip_device_license import ModuleManager + from ansible.modules.network.f5.bigip_device_license import ArgumentSpec + from ansible.module_utils.network.f5.common import F5ModuleError + from ansible.module_utils.network.f5.common import iControlUnexpectedHTTPError + from units.modules.utils import set_module_args + except ImportError: + raise SkipTest("F5 Ansible modules require the f5-sdk Python library") + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +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( + license_key='xxxx-yyyy-zzzz', + license_server='foo-license.f5.com', + accept_eula=True + ) + + p = ModuleParameters(params=args) + assert p.license_key == 'xxxx-yyyy-zzzz' + assert p.license_server == 'foo-license.f5.com' + assert p.accept_eula is True + + +class TestModuleManager(unittest.TestCase): + + def setUp(self): + self.spec = ArgumentSpec() + self.patcher1 = patch('time.sleep') + self.patcher1.start() + + def tearDown(self): + self.patcher1.stop() + + def test_create(self, *args): + set_module_args( + dict( + license_key='xxxx-yyyy-zzzz', + license_server='foo-license.f5.com', + accept_eula=True, + server='localhost', + user='admin', + password='password' + ) + ) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode + ) + mm = ModuleManager(module=module) + + # Override methods to force specific logic in the module to happen + mm.exists = Mock(side_effect=[False, True]) + mm.read_dossier_from_device = Mock(return_value=True) + mm.generate_license_from_remote = Mock(return_value=True) + mm.upload_license_to_device = Mock(return_value=True) + mm.upload_eula_to_device = Mock(return_value=True) + mm.reload_license = Mock(return_value=True) + mm._is_mcpd_ready_on_device = Mock(return_value=True) + + results = mm.exec_module() + assert results['changed'] is True