From dcf833d31ce6b3e2182ed047d1d4c00d51a39e8b Mon Sep 17 00:00:00 2001 From: Wojciech Wypior Date: Tue, 19 Mar 2019 23:15:43 +0100 Subject: [PATCH] adds new module to manage bigip devices on the BIGIQ (#53987) --- .../network/f5/bigiq_device_discovery.py | 1239 +++++++++++++++++ .../f5/fixtures/load_machine_resolver.json | 187 +++ .../network/f5/test_bigiq_device_discovery.py | 134 ++ 3 files changed, 1560 insertions(+) create mode 100644 lib/ansible/modules/network/f5/bigiq_device_discovery.py create mode 100644 test/units/modules/network/f5/fixtures/load_machine_resolver.json create mode 100644 test/units/modules/network/f5/test_bigiq_device_discovery.py diff --git a/lib/ansible/modules/network/f5/bigiq_device_discovery.py b/lib/ansible/modules/network/f5/bigiq_device_discovery.py new file mode 100644 index 00000000000..ded36f1ece2 --- /dev/null +++ b/lib/ansible/modules/network/f5/bigiq_device_discovery.py @@ -0,0 +1,1239 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2019, 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': 'certified'} + +DOCUMENTATION = r''' +--- +module: bigiq_device_discovery +short_description: Manage BIG-IP devices through BIG-IQ +description: + - Discovers and imports BIG-IP device configuration on the BIG-IQ. +version_added: 2.8 +options: + device_address: + description: + - The IP address of the BIG-IP device to be imported/managed. + type: str + required: True + device_username: + description: + - The administrator username for the BIG-IP device. + - This parameter is only required when adding a new BIG-IP device to be managed. + type: str + device_password: + description: + - The administrator password for the BIG-IP device. + - This parameter is only required when adding a new BIG-IP device to be managed. + type: str + device_port: + description: + - The port on which a device trust setup between BIG-IQ and BIG-IP should happen. + type: int + default: 443 + ha_name: + description: + - DSC cluster name of the BIG-IP device to be managed. + - This is optional if the managed device is not a part of a cluster group. + - When C(use_bigiq_sync) is set to C(yes) then this parameter becomes mandatory. + type: str + use_bigiq_sync: + description: + - When set to true, BIG-IQ will manually synchronize configuration changes + between members in a DSC cluster. + type: bool + default: no + conflict_policy: + description: + - Sets the conflict resolution policy for shared objects across BIG-IP devices, except LTM profiles and monitors. + type: str + choices: + - use_bigiq + - use_bigip + default: use_bigiq + versioned_conflict_policy: + description: + - Sets the conflict resolution policy for LTM profile and monitor objects that are specific to a BIG-IP software + version. + type: str + choices: + - use_bigiq + - use_bigip + - keep_version + device_conflict_policy: + description: + - Sets the conflict resolution policy for objects that are specific to a particular to a BIG-IP device + and not shared among BIG-IP devices. + type: str + choices: + - use_bigiq + - use_bigip + default: use_bigiq + access_conflict_policy: + description: + - Sets the conflict resolution policy for Access module C(apm) objects, only used when C(apm) module is specified. + type: str + choices: + - use_bigiq + - use_bigip + - keep_version + access_group_name: + description: + - Access group name to import Access configuration for devices, once set it cannot be changed. + type: str + access_group_first_device: + description: + - Specifies if the imported device is the first device in the access group to import shared configuration for that + access group. + type: bool + default: yes + force: + description: + - Forces rediscovery and import of existing modules on the managed BIG-IP + type: bool + default: no + modules: + description: + - List of modules to be discovered and imported into the device. + - These modules must be provisioned on the target device otherwise operation will fail. + - The C(ltm) module must always be specified when performing discovery or re-discovery of the the device. + - When C(asm) or C(afm) are specified C(shared_security) module needs to also be declared. + type: list + choices: + - ltm + - asm + - apm + - afm + - dns + - websafe + - security_shared + statistics: + description: + - Specify the statistics collection for discovered device. + suboptions: + enable: + description: + - Enables statistics collection on a device + type: bool + default: no + interval: + description: + - Specify the interval in seconds the data is collected from the discovered device. + type: int + default: 60 + choices: + - 30 + - 60 + - 120 + - 500 + zone: + description: + - Specify in which DCD zone is collecting the data from device. + type: str + default: default + stat_modules: + description: + - Specifies for which modules the data is being collected. + type: list + default: ['device', 'ltm'] + choices: + - device + - ltm + - dns + state: + description: + - The state of the managed device on the system. + - When C(present), enables new device addition as well as device rediscovery/import. + - When C(absent), completely removes the device from the system. + type: str + choices: + - absent + - present + default: present +extends_documentation_fragment: f5 +notes: + - BIG-IQ >= 6.1.0. + - This module does not support atomic removal of discovered modules on the device. +author: + - Wojciech Wypior (@wojtek0806) +''' + +EXAMPLES = r''' +- name: Discover a new device and import config, use default conflict policy. + bigiq_device_discovery: + device_address: 192.168.1.1 + device_username: bigipadmin + device_password: bigipsecret + modules: + - ltm + - afm + - shared_security + provider: + password: secret + server: lb.mydomain.com + user: admin + delegate_to: localhost + +- name: Discover a new device and import config, use non- default conflict policy. + bigiq_device_discovery: + device_address: 192.168.1.1 + modules: + - ltm + - dns + conflict_policy: use_bigip + provider: + password: secret + server: lb.mydomain.com + user: admin + delegate_to: localhost + +- name: Force full device rediscovery + bigiq_device_discovery: + device_address: 192.168.1.1 + modules: + - ltm + - afm + - dns + - shared_security + force: yes + provider: + password: secret + server: lb.mydomain.com + user: admin + delegate_to: localhost + +- name: Remove discovered device and its config + bigiq_device_discovery: + device_address: 192.168.1.1 + state: absent + provider: + password: secret + server: lb.mydomain.com + user: admin + delegate_to: localhost +''' + +RETURN = r''' +device_address: + description: The IP address of the BIG-IP device to be imported/managed. + returned: changed + type: str + sample: 192.168.1.1 +device_port: + description: The port on which a device trust setup between BIG-IQ and BIG-IP should happen. + returned: changed + type: int + sample: 10443 +ha_name: + description: DSC cluster name of the BIG-IP device to be managed. + returned: changed + type: str + sample: GROUP_1 +use_bigiq_sync: + description: Indicate if BIG-IQ should manually synchronise DSC configuration. + returned: changed + type: bool + sample: yes +conflict_policy: + description: Sets the conflict resolution policy for shared objects across BIG-IP devices. + returned: changed + type: str + sample: use_bigip +device_conflict_policy: + description: Sets the conflict resolution policy for objects that are specific to a particular to a BIG-IP device. + returned: changed + type: str + sample: use_bigip +versioned_conflict_policy: + description: Sets the conflict resolution policy for LTM profile and monitor objects. + returned: changed + type: str + sample: keep_version +access_conflict_policy: + description: Sets the conflict resolution policy for Access module C(apm) objects. + returned: changed + type: str + sample: keep_version +access_group_name: + description: Access group name to import Access configuration for devices. + returned: changed + type: str + sample: foo_group +access_group_first_device: + description: First device in the access group to import shared configuration for that access group. + returned: changed + type: bool + sample: yes +modules: + description: List of modules to be discovered and imported into the device. + returned: changed + type: list + sample: ['ltm', 'dns'] + +''' + +import time +from distutils.version import LooseVersion +from ansible.module_utils.basic import AnsibleModule + +try: + from library.module_utils.network.f5.bigiq import F5RestClient + 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 f5_argument_spec + from library.module_utils.network.f5.common import flatten_boolean + from library.module_utils.network.f5.ipaddress import is_valid_ip + from library.module_utils.network.f5.icontrol import bigiq_version +except ImportError: + from ansible.module_utils.network.f5.bigiq import F5RestClient + 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 f5_argument_spec + from ansible.module_utils.network.f5.common import flatten_boolean + from ansible.module_utils.network.f5.ipaddress import is_valid_ip + from ansible.module_utils.network.f5.icontrol import bigiq_version + + +class Parameters(AnsibleF5Parameters): + api_map = { + 'address': 'device_address', + 'userName': 'device_username', + 'password': 'device_password', + 'httpsPort': 'device_port', + 'clusterName': 'ha_name', + 'useBigiqSync': 'use_bigiq_sync', + } + + api_attributes = [ + 'address', + 'userName', + 'password', + 'httpsPort', + 'clusterName', + 'useBigiqSync', + ] + + returnables = [ + 'device_address', + 'device_username', + 'device_password', + 'device_port', + 'ha_name', + 'use_bigiq_sync', + 'modules', + 'conflict_policy', + 'versioned_conflict_policy', + 'device_conflict_policy', + 'access_group_name', + 'access_group_first_device', + 'access_conflict_policy', + 'module_list', + 'apm_properties', + ] + + updatables = [ + 'modules', + 'access_group_name', + 'apm_properties', + 'module_list', + ] + + +class ApiParameters(Parameters): + module_map = { + 'cm-security-shared-allSharedDevices': 'security_shared', + 'cm-asm-allAsmDevices': 'asm', + 'cm-firewall-allFirewallDevices': 'firewall', + 'cm-websafe-allFpsDevices': 'fps', + 'cm-dns-allBigIpDevices': 'dns', + 'cm-adccore-allbigipDevices': 'adc_core', + 'cm-access-allBigIpDevices': 'access', + } + + @property + def modules(self): + raw_data = self._values['properties'] + if raw_data is None: + return None + result = list() + for item in raw_data.keys(): + if item in self.module_map: + if raw_data[item]['discovered'] is True and raw_data[item]['imported'] is True: + result.append(self.module_map[item]) + return result + + @property + def access_group_name(self): + raw_data = self._values['properties'] + if raw_data is None: + return None + for item in raw_data.keys(): + if 'cm:access:access-group-name' in raw_data[item]: + return raw_data[item]['cm:access:access-group-name'] + return None + + +class ModuleParameters(Parameters): + module_map = { + 'ltm': 'adc_core', + 'afm': 'firewall', + 'websafe': 'fps', + 'apm': 'access', + } + + @property + def device_password(self): + if self._values['device_password'] is None: + return None + return self._values['device_password'] + + @property + def device_username(self): + if self._values['device_username'] is None: + return None + return self._values['device_username'] + + @property + def device_address(self): + if is_valid_ip(self._values['device_address']): + return self._values['device_address'] + raise F5ModuleError( + 'Provided device address: {0} is not a valid IP.'.format(self._values['device_address']) + ) + + @property + def device_port(self): + if self._values['device_port'] is None: + return None + return int(self._values['device_port']) + + @property + def conflict_policy(self): + return self._values['conflict_policy'].upper() + + @property + def device_conflict_policy(self): + return self._values['device_conflict_policy'].upper() + + @property + def versioned_conflict_policy(self): + if self._values['versioned_conflict_policy'] is None: + return None + return self._values['versioned_conflict_policy'].upper() + + @property + def access_conflict_policy(self): + if self._values['access_conflict_policy'] is None: + return None + return self._values['device_conflict_policy'].upper() + + @property + def modules(self): + if self._values['modules'] is None: + return None + result = list() + if 'security_shared' not in self._values['modules']: + if 'afm' in self._values['modules']: + raise F5ModuleError( + "Module 'shared_security' required for 'afm' module." + ) + if 'asm' in self._values['modules']: + raise F5ModuleError( + "Module 'shared_security' required for 'asm' module." + ) + if 'ltm' not in self._values['modules']: + raise F5ModuleError( + "LTM module must be specified for device discovery and import." + ) + if 'apm' in self._values['modules']: + if not self.access_group_name or not self.access_conflict_policy: + raise F5ModuleError( + "When importing APM 'access_group_name' and 'access_conflict_policy' must be specified." + ) + for item in self._values['modules']: + if item in self.module_map: + result.append(self.module_map[item]) + else: + result.append(item) + return result + + @property + def apm_properties(self): + if self._values['modules'] is None: + return None + if 'apm' in self._values['modules']: + result = { + 'cm:access:conflict-resolution': self.access_conflict_policy, + 'cm:access:access-group-name': self.access_group_name, + 'cm:access:import-shared': self.access_group_first_device + } + return result + + @property + def use_bigiq_sync(self): + result = flatten_boolean(self._values['use_bigiq_sync']) + if result: + if result == 'yes': + return True + return False + + @property + def access_group_first_device(self): + result = flatten_boolean(self._values['access_group_first_device']) + if result: + if result == 'yes': + return True + return False + + @property + def stats_enabled(self): + if self._values['statistics'] is None: + return None + result = flatten_boolean(self._values['statistics']['enable']) + if result: + if result == 'yes': + return True + return False + + @property + def interval(self): + if self._values['statistics'] is None: + return None + return self._values['statistics']['interval'] + + @property + def zone(self): + if self._values['statistics'] is None: + return None + return self._values['statistics']['zone'] + + @property + def stat_modules(self): + if self._values['statistics'] is None: + return None + modules = self._values['statistics']['stat_modules'] + result = list() + for module in modules: + result.append((dict(module=module.upper()))) + 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): + @property + def modules(self): + if self._values['modules'] is None: + return None + result = list() + for item in self._values['modules']: + result.append(dict(module=item)) + return result + + @property + def module_list(self): + if self._values['modules'] is None: + return None + result = list() + for item in self._values['modules']: + if item == 'access': + result.append(dict(module=item, properties=self._values['apm_properties'])) + else: + result.append(dict(module=item)) + return result + + +class ReportableChanges(Changes): + @property + def module_list(self): + return None + + @property + def apm_properties(self): + return None + + +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 + + @property + def modules(self): + if self.want.modules is None: + return None + if self.have.modules is None: + return self.want.modules + if set(self.want.modules).issubset(self.have.modules): + return None + if set(self.want.modules) != set(self.have.modules): + return self.want.modules + + @property + def access_group_name(self): + if self.want.access_group_name != self.have.access_group_name: + raise F5ModuleError( + 'Access group name cannot be modified once it is set.' + ) + + @property + def apm_properties(self): + # This is required for idempotency and updates as we do not compare these properties + return None + + +class ModuleManager(object): + def __init__(self, *args, **kwargs): + self.module = kwargs.get('module', None) + self.client = F5RestClient(**self.module.params) + self.want = ModuleParameters(params=self.module.params) + self.have = ApiParameters() + self.changes = UsableChanges() + self.device_id = None + self.task_id = None + + 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: + changed['apm_properties'] = self.want.apm_properties + self.changes = UsableChanges(params=changed) + return True + return False + + def _announce_deprecations(self, result): + warnings = result.pop('__warnings', []) + for warning in warnings: + self.client.module.deprecate( + msg=warning['msg'], + version=warning['version'] + ) + + def check_bigiq_version(self): + version = bigiq_version(self.client) + if LooseVersion(version) < LooseVersion('6.1.0'): + raise F5ModuleError( + 'Module supports only BIGIQ version 6.1.x or higher.' + ) + + def exec_module(self): + self.check_bigiq_version() + changed = False + result = dict() + state = self.want.state + + if state == "present": + changed = self.present() + elif state == "absent": + changed = self.absent() + + 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 present(self): + if self.exists(): + return self.update() + else: + return self.create() + + def absent(self): + if self.exists(): + return self.remove() + return False + + def should_update(self): + result = self._update_changed_options() + if result: + return True + return False + + def update(self): + self.have = self.read_current_from_device() + if not self.should_update() and not self.want.force: + return False + if self.module.check_mode: + return True + if self.want.force: + self._set_changed_options() + self.discover_on_device() + self.import_modules_on_device() + if self.want.stats_enabled: + self.enable_stats_on_device() + return True + + def remove(self): + if self.module.check_mode: + return True + self.remove_autority_from_device() + self.remove_trust_from_device() + return True + + def create(self): + if self.want.modules is None: + raise F5ModuleError( + 'List of modules cannot be empty if discovering a device.' + ) + self._set_changed_options() + if self.module.check_mode: + return True + self.set_trust_with_device() + self.discover_on_device() + self.import_modules_on_device() + if self.want.stats_enabled: + self.enable_stats_on_device() + return True + + def exists(self): + uri = "https://{0}:{1}/mgmt/cm/system/machineid-resolver".format( + self.client.provider['server'], + self.client.provider['server_port'], + ) + + query = "?$filter=address eq '{0}'".format(self.want.device_address) + resp = self.client.api.get(uri + query) + + try: + response = resp.json() + except ValueError: + return False + + if resp.status == 404 or 'code' in response and response['code'] == 404: + raise F5ModuleError(response.message) + + if 'items' in response: + if not response['items']: + return False + self.device_id = response['items'][0]['machineId'] + return True + return False + + def set_trust_with_device(self): + params = self.changes.api_params() + params['name'] = 'trust_{0}'.format(self.want.device_address) + uri = "https://{0}:{1}/mgmt/cm/global/tasks/device-trust/".format( + self.client.provider['server'], + self.client.provider['server_port'], + ) + resp = self.client.api.post(uri, json=params) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] in [400, 409]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + task = "https://{0}:{1}/mgmt/cm/global/tasks/device-trust/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + response['id'] + ) + query = "?$select=status,currentStep,errorMessage" + + if self._wait_for_task(task + query): + self._set_device_id(task) + return True + + def _set_device_id(self, uri): + resp = self.client.api.get(uri) + + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] == 400: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + self.device_id = response['machineId'] + + def _wait_for_task(self, uri): + while True: + resp = self.client.api.get(uri) + + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] == 400: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + if response['status'] in ['FINISHED', 'FAILED', 'CANCELLED']: + break + + time.sleep(1) + + if response['status'] == 'FAILED': + raise F5ModuleError(response['errorMessage']) + if response['status'] == 'CANCELLED': + raise F5ModuleError( + 'The task process has been cancelled.' + ) + if response['status'] == 'FINISHED': + return True + + def discover_on_device(self): + tmp = self.changes.to_return() + if self.reuse_task_on_device('discovery'): + params = dict( + moduleList=tmp['modules'], + status='STARTED' + ) + uri = "https://{0}:{1}/mgmt/cm/global/tasks/device-discovery/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + self.task_id + ) + resp = self.client.api.patch(uri, json=params) + + else: + params = dict( + name='discovery_{0}'.format(self.want.device_address), + moduleList=tmp['modules'], + deviceReference=dict(link='https://localhost/mgmt/cm/system/machineid-resolver/{0}'.format( + self.device_id + ) + ), + status='STARTED' + ) + uri = "https://{0}:{1}/mgmt/cm/global/tasks/device-discovery".format( + self.client.provider['server'], + self.client.provider['server_port'], + ) + resp = self.client.api.post(uri, json=params) + + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] in [400, 409]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + task = "https://{0}:{1}/mgmt/cm/global/tasks/device-discovery/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + response['id'] + ) + query = "?$select=status,currentStep,errorMessage" + + self._wait_for_task(task + query) + + return True + + def import_modules_on_device(self): + tmp = self.changes.to_return() + if self.reuse_task_on_device('import'): + params = dict( + moduleList=tmp['module_list'], + conflictPolicy=self.want.conflict_policy, + deviceConflictPolicy=self.want.device_conflict_policy, + status='STARTED' + ) + + if self.want.versioned_conflict_policy: + params['versionedConflictPolicy'] = self.want.versioned_conflict_policy + + uri = "https://{0}:{1}/mgmt/cm/global/tasks/device-import/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + self.task_id + ) + resp = self.client.api.patch(uri, json=params) + + else: + params = dict( + name='import_{0}'.format(self.want.device_address), + moduleList=tmp['module_list'], + conflictPolicy=self.want.conflict_policy, + deviceConflictPolicy=self.want.device_conflict_policy, + deviceReference=dict(link='https://localhost/mgmt/cm/system/machineid-resolver/{0}'.format( + self.device_id + ) + ), + status='STARTED' + ) + + if self.want.versioned_conflict_policy: + params['versionedConflictPolicy'] = self.want.versioned_conflict_policy + + uri = "https://{0}:{1}/mgmt/cm/global/tasks/device-import".format( + self.client.provider['server'], + self.client.provider['server_port'], + ) + resp = self.client.api.post(uri, json=params) + + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] in [400, 409]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + task = "https://{0}:{1}/mgmt/cm/global/tasks/device-import/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + response['id'] + ) + query = "?$select=status,currentStep,errorMessage" + + self._wait_for_task(task + query) + + return True + + def enable_stats_on_device(self): + params = dict( + enabled=self.want.stats_enabled, + pushIntervalSecs=self.want.interval, + zone=self.want.zone, + modules=self.want.stat_modules, + targetDeviceReference=dict( + link='https://localhost/mgmt/cm/system/machineid-resolver/{0}'.format( + self.device_id + ) + ), + ) + + uri = "https://{0}:{1}/mgmt/cm/shared/stats-mgmt/agent-install-and-config-task".format( + self.client.provider['server'], + self.client.provider['server_port'], + ) + resp = self.client.api.post(uri, json=params) + + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] in [400, 409]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + task = "https://{0}:{1}/mgmt/cm/shared/stats-mgmt/agent-install-and-config-task/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + response['id'] + ) + query = "?$select=status,currentStep,errorMessage" + + self._wait_for_task(task + query) + + return True + + def reuse_task_on_device(self, task): + if task == 'discovery': + uri = "https://{0}:{1}/mgmt/cm/global/tasks/device-discovery".format( + self.client.provider['server'], + self.client.provider['server_port'], + ) + else: + uri = "https://{0}:{1}/mgmt/cm/global/tasks/device-import".format( + self.client.provider['server'], + self.client.provider['server_port'], + ) + + query = "?$filter=deviceReference/link eq '{0}'".format(self.device_id) + resp = self.client.api.get(uri + query) + + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] in [400, 409]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + if 'items' in response: + if response['items']: + self.task_id = response['id'] + return True + return False + + def remove_autority_from_device(self): + # We can provide all of the modules for removal task, without ensuring they were discovered + modules = [ + {'module': 'adc_core'}, + {'module': 'access'}, + {'module': 'asm'}, + {'module': 'fps'}, + {'module': 'firewall'}, + {'module': 'security_shared'}, + {'module': 'dns'} + ] + params = dict( + moduleList=modules, + deviceReference=dict( + link="https://localhost/mgmt/cm/system/machineid-resolver/{0}".format(self.device_id) + ), + name='remove_auth_{0}'.format(self.want.device_address) + + ) + uri = "https://{0}:{1}/mgmt/cm/global/tasks/device-remove-mgmt-authority/".format( + self.client.provider['server'], + self.client.provider['server_port'], + ) + resp = self.client.api.post(uri, json=params) + + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] in [400, 409]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + task = "https://{0}:{1}/mgmt/cm/global/tasks/device-remove-mgmt-authority/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + response['id'] + ) + query = "?$select=status,currentStep,errorMessage" + + self._wait_for_task(task + query) + + def remove_trust_from_device(self): + params = dict( + deviceReference=dict( + link="https://localhost/mgmt/cm/system/machineid-resolver/{0}".format(self.device_id) + ), + name='remove_auth_{0}'.format(self.want.device_address) + + ) + uri = "https://{0}:{1}/mgmt/cm/global/tasks/device-remove-trust/".format( + self.client.provider['server'], + self.client.provider['server_port'], + ) + resp = self.client.api.post(uri, json=params) + + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] in [400, 409]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + task = "https://{0}:{1}/mgmt/cm/global/tasks/device-remove-trust/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + response['id'] + ) + query = "?$select=status,currentStep,errorMessage" + + self._wait_for_task(task + query) + + def read_current_from_device(self): + uri = "https://{0}:{1}/mgmt/cm/system/machineid-resolver/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + self.device_id + ) + resp = self.client.api.get(uri) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] == 400: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + return ApiParameters(params=response) + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + self.conflict = ['use_bigip', 'use_bigiq'] + argument_spec = dict( + device_address=dict( + required=True + ), + device_username=dict( + no_log=True + ), + device_password=dict( + no_log=True + ), + device_port=dict( + type='int', + default=443 + ), + ha_name=dict(), + use_bigiq_sync=dict( + type='bool', + default='no' + ), + conflict_policy=dict( + choices=self.conflict, + default='use_bigiq' + ), + versioned_conflict_policy=dict( + choices=self.conflict + ['keep_version'], + ), + device_conflict_policy=dict( + choices=self.conflict, + default='use_bigiq' + ), + force=dict( + type='bool', + default='no' + ), + modules=dict( + type='list', + choices=[ + 'ltm', 'asm', 'afm', 'dns', 'websafe', 'security_shared', 'apm' + ] + ), + access_conflict_policy=dict( + choices=self.conflict + ['keep_version'] + ), + access_group_name=dict(), + access_group_first_device=dict( + type='bool', + default='yes' + ), + statistics=dict( + type='dict', + options=dict( + enable=dict( + type='bool', + default='no' + ), + interval=dict( + type='int', + choices=[ + 30, 60, 120, 500 + ], + default=60 + ), + zone=dict( + type='str', + default='default' + ), + stat_modules=dict( + type='list', + choices=[ + 'device', 'ltm', 'dns' + ], + default=[ + 'device', 'ltm' + ] + ) + ) + + ), + state=dict(default='present', choices=['absent', 'present']), + ) + self.required_if = [ + ['use_bigiq_sync', True, ['ha_name']] + ] + self.argument_spec = {} + self.argument_spec.update(f5_argument_spec) + self.argument_spec.update(argument_spec) + + +def main(): + spec = ArgumentSpec() + + module = AnsibleModule( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + required_if=spec.required_if + ) + + try: + mm = ModuleManager(module=module) + results = mm.exec_module() + module.exit_json(**results) + except F5ModuleError as ex: + module.fail_json(msg=str(ex)) + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/network/f5/fixtures/load_machine_resolver.json b/test/units/modules/network/f5/fixtures/load_machine_resolver.json new file mode 100644 index 00000000000..2422ca6db82 --- /dev/null +++ b/test/units/modules/network/f5/fixtures/load_machine_resolver.json @@ -0,0 +1,187 @@ + { + "uuid": "4dd9f559-c1b9-4e05-8d17-2345a6a3d459", + "deviceUri": "https://10.144.74.229:443", + "machineId": "4dd9f559-c1b9-4e05-8d17-2345a6a3d459", + "state": "ACTIVE", + "address": "10.144.74.229", + "httpsPort": 443, + "hostname": "ansible_test_lab12.lab.local", + "version": "12.1.3", + "product": "BIG-IP", + "edition": "Final", + "build": "0.0.378", + "restFrameworkVersion": "12.1.3-0.0.378", + "managementAddress": "10.144.74.229", + "mcpDeviceName": "/Common/ansible_test_lab12.lab.local", + "trustDomainGuid": "44135337-f809-480d-ab6ffa163edc9ff6", + "properties": { + "cm:gui:module": [ + "asmsecurity", + "adc", + "BigIPDevice", + "sharedsecurity" + ], + "modules": [ + "Web Application Security Group", + "Security" + ], + "cm-bigip-allBigIpDevices": { + "cm:gui:module": [ + "asmsecurity", + "adc", + "BigIPDevice", + "sharedsecurity" + ], + "shared:resolver:device-groups:discoverer": "13446925-efb3-47f4-b32c-ed705d29e878", + "modules": [ + "Web Application Security Group", + "Security" + ] + }, + "cm-asm-allDevices": { + "cm:gui:module": [], + "modules": [] + }, + "cm-bigip-allDevices": { + "shared:resolver:device-groups:discoverer": "13446925-efb3-47f4-b32c-ed705d29e878", + "cm:gui:module": [], + "modules": [] + }, + "cm-adccore-allDevices": { + "cm:gui:module": [], + "modules": [] + }, + "cm-security-shared-allSharedDevices": { + "discovered": true, + "imported": true, + "supportsAlpineDosDeviceConfig": true, + "supports_14_0_Enhs": false, + "supportsRest": true, + "supportsAlpineDosProfileEnhs": true, + "requiresDhcpProfileInDhcpVirtualServer": true, + "supportsAfmSubscribers": false, + "supportsAlpineEnhs": true, + "supports_13_0_Enhs": false, + "supportsFirewallRuleIdentifiers": false, + "supportsBadgerEnhs": true, + "supportsAlpineDosDeviceWhitelistIpProcotol": true, + "supportsSshProfile": true, + "supportsPortMisusePolicy": true, + "supportsAlpineLogProfileEnhs": true, + "supportsCascadeEnhs": true, + "supportUdpPortList": true, + "supports_13_1_Enhs": false, + "supportsIncrementalDiscovery": false, + "lastDiscoveredDateTime": "2019-02-12T13:53:06.541Z", + "lastUserDiscoveredDateTime": "2019-02-12T13:53:06.541Z", + "importedDateTime": "2019-02-12T13:53:24.885Z", + "discoveryStatus": "FINISHED", + "importStatus": "FINISHED", + "cm:gui:module": [ + "sharedsecurity" + ], + "modules": [ + "Security" + ] + }, + "cm-adccore-allbigipDevices": { + "discovered": true, + "imported": true, + "supportsRest": true, + "requiresDhcpProfileInDhcpVirtualServer": true, + "supportsAlpineEnhs": true, + "supports_13_0_Enhs": false, + "supportsFirewallRuleIdentifiers": false, + "supportsBadgerEnhs": true, + "restrictsPortTranslationStatelessVirtual": true, + "supportsClassification": true, + "supports_13_1_Enhs": false, + "supportsIncrementalDiscovery": false, + "supports_12_1_2_Enhs": true, + "lastDiscoveredDateTime": "2019-02-12T13:53:03.963Z", + "lastUserDiscoveredDateTime": "2019-02-12T13:53:03.963Z", + "importedDateTime": "2019-02-12T13:53:18.975Z", + "discoveryStatus": "FINISHED", + "importStatus": "FINISHED", + "cm:gui:module": [ + "adc" + ], + "modules": [] + }, + "cm-security-shared-allDevices": { + "cm:gui:module": [], + "modules": [] + }, + "cm-asm-allAsmDevices": { + "discovered": true, + "imported": true, + "supportsHostNameEnforcementMode": false, + "supportsRest": true, + "supportsServerTechnologies": false, + "supportsCpb": false, + "supportsUrlCascadeFeatures": true, + "supportsSessionTrackingAllLoginPagesUsernameSource": true, + "supportsLoginEnforcementCascadeFeatures": true, + "suppportsXmlValidationFiles": true, + "supportsExtractions": true, + "supportsWebSocketSecurity": true, + "supportsWhitelistIpBlockRequestAlways": false, + "supportsSessionTrackingSessionHijackingByDeviceId": true, + "supportsLoginPagesHeaderOmits": false, + "supportsBruteForceAttackPreventionsCascadeFeatures": true, + "supportsPlainTextProfile": true, + "supportsIncrementalDiscovery": false, + "supportsRedirectionProtection": true, + "supportsHeaderSignaturesOverride": false, + "supportsIpIntelligence": true, + "supports_13_0_Enhs": false, + "supportsFirewallRuleIdentifiers": false, + "supportsSessionTrackingDeviceIdThresholds": true, + "supportsLoginEnforcement": true, + "supportsCsrfProtection": true, + "supportsSessionTracking": true, + "supportsJsonProfiles": true, + "supportsBruteForceAttackPreventions": true, + "supportsWebScraping": true, + "supportsLoginPagesCascadeFeatures": true, + "supportsGwtProfiles": true, + "supportsXmlProfiles": true, + "supportsAsmDisallowedGeolocation": true, + "supportsCsrfUrls": false, + "supportsDataProtection": false, + "supportsLoginPages": true, + "supportsBruteForceAttackPreventionsBadgerFeatures": true, + "supportsUrlSignaturesOverride": false, + "signatureAutoUpdateState": true, + "signatureFileVersion": 1.450112674E12, + "signatureFilename": "Attack Signature Database packaged with version 12.1.3", + "lastDiscoveredDateTime": "2019-02-12T13:53:09.188Z", + "lastUserDiscoveredDateTime": "2019-02-12T13:53:09.188Z", + "importedDateTime": "2019-02-12T13:53:29.730Z", + "discoveryStatus": "FINISHED", + "importStatus": "FINISHED", + "cm:gui:module": [ + "asmsecurity" + ], + "modules": [ + "Web Application Security Group" + ] + } + }, + "isClustered": false, + "isVirtual": true, + "isLicenseExpired": false, + "slots": [ + { + "volume": "HD1.1", + "product": "BIG-IP", + "version": "12.1.3", + "build": "0.0.378", + "isActive": true + } + ], + "generation": 4, + "lastUpdateMicros": 1549979318078796, + "kind": "shared:resolver:device-groups:restdeviceresolverdevicestate", + "selfLink": "https://localhost/mgmt/cm/system/machineid-resolver/4dd9f559-c1b9-4e05-8d17-2345a6a3d459" + } diff --git a/test/units/modules/network/f5/test_bigiq_device_discovery.py b/test/units/modules/network/f5/test_bigiq_device_discovery.py new file mode 100644 index 00000000000..efcd3769b43 --- /dev/null +++ b/test/units/modules/network/f5/test_bigiq_device_discovery.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2019, 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 pytest +import sys + +if sys.version_info < (2, 7): + pytestmark = pytest.mark.skip("F5 Ansible modules require Python >= 2.7") + +from ansible.module_utils.basic import AnsibleModule + +try: + from library.modules.bigiq_device_discovery import ApiParameters + from library.modules.bigiq_device_discovery import ModuleParameters + from library.modules.bigiq_device_discovery import ModuleManager + from library.modules.bigiq_device_discovery import ArgumentSpec + + # In Ansible 2.8, Ansible changed import paths. + from test.units.compat import unittest + from test.units.compat.mock import Mock + from test.units.compat.mock import patch + + from test.units.modules.utils import set_module_args +except ImportError: + from ansible.modules.network.f5.bigiq_device_discovery import ApiParameters + from ansible.modules.network.f5.bigiq_device_discovery import ModuleParameters + from ansible.modules.network.f5.bigiq_device_discovery import ModuleManager + from ansible.modules.network.f5.bigiq_device_discovery import ArgumentSpec + + # Ansible 2.8 imports + from units.compat import unittest + from units.compat.mock import Mock + from units.compat.mock import patch + + from units.modules.utils import set_module_args + + +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( + device_address='192.168.1.1', + device_username='admin', + device_password='admin', + device_port=10443, + ha_name='bazfoo', + use_bigiq_sync='yes', + modules=['asm', 'ltm', 'security_shared'] + ) + + p = ModuleParameters(params=args) + assert p.device_address == '192.168.1.1' + assert p.device_username == 'admin' + assert p.device_password == 'admin' + assert p.device_port == 10443 + assert p.ha_name == 'bazfoo' + assert p.use_bigiq_sync is True + assert p.modules == ['asm', 'adc_core', 'security_shared'] + + def test_api_parameters(self): + args = load_fixture('load_machine_resolver.json') + + p = ApiParameters(params=args) + assert sorted(p.modules) == sorted(['asm', 'adc_core', 'security_shared']) + + +class TestManager(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( + device_address='192.168.1.1', + device_username='admin', + device_password='admin', + modules=['asm', 'ltm', 'security_shared'], + provider=dict( + password='password', + server='localhost', + user='admin' + ) + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + required_if=self.spec.required_if + ) + mm = ModuleManager(module=module) + + # Override methods to force specific logic in the module to happen + mm.exists = Mock(side_effect=[False, True]) + mm.set_trust_with_device = Mock(return_value=True) + mm.discover_on_device = Mock(return_value=True) + mm.import_modules_on_device = Mock(return_value=True) + mm.check_bigiq_version = Mock(return_value=True) + + results = mm.exec_module() + + assert results['changed'] is True