diff --git a/lib/ansible/modules/network/f5/bigip_gtm_pool.py b/lib/ansible/modules/network/f5/bigip_gtm_pool.py index c9e688aa652..985bdc27bd2 100644 --- a/lib/ansible/modules/network/f5/bigip_gtm_pool.py +++ b/lib/ansible/modules/network/f5/bigip_gtm_pool.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- # -# Copyright (c) 2017 F5 Networks Inc. +# 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 @@ -177,9 +177,38 @@ options: - This parameter is only relevant when a C(type) of C(require) is used. - This parameter will be ignored if a type of either C(all) or C(at_least) is used. version_added: 2.6 + max_answers_returned: + description: + - Specifies the maximum number of available virtual servers that the system lists in a response. + - The maximum is 500. + version_added: 2.8 + ttl: + description: + - Specifies the number of seconds that the IP address, once found, is valid. + version_added: 2.8 extends_documentation_fragment: f5 author: - Tim Rupp (@caphrim007) + - Wojciech Wypior (@wojtek0806) +''' + +EXAMPLES = r''' +- name: Create a GTM pool + bigip_gtm_pool: + server: lb.mydomain.com + user: admin + password: secret + name: my_pool + delegate_to: localhost + +- name: Disable pool + bigip_gtm_pool: + server: lb.mydomain.com + user: admin + password: secret + state: disabled + name: my_pool + delegate_to: localhost ''' RETURN = r''' @@ -221,25 +250,11 @@ members: description: The name of the virtual server portion of the member. returned: changed type: string -''' - -EXAMPLES = r''' -- name: Create a GTM pool - bigip_gtm_pool: - server: lb.mydomain.com - user: admin - password: secret - name: my_pool - delegate_to: localhost - -- name: Disable pool - bigip_gtm_pool: - server: lb.mydomain.com - user: admin - password: secret - state: disabled - name: my_pool - delegate_to: localhost +max_answers_returned: + description: The new Maximum Answers Returned value. + returned: changed + type: int + sample: 25 ''' import copy @@ -250,33 +265,31 @@ from ansible.module_utils.basic import env_fallback from distutils.version import LooseVersion 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.bigip 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 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.common import transform_name + from library.module_utils.network.f5.common import exit_json + from library.module_utils.network.f5.common import fail_json + from library.module_utils.network.f5.icontrol import tmos_version + from library.module_utils.network.f5.icontrol import module_provisioned from library.module_utils.network.f5.ipaddress import is_valid_ip - try: - from library.module_utils.network.f5.common import iControlUnexpectedHTTPError - from f5.sdk_exception import LazyAttributesRequired - 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.bigip 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 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.common import transform_name + from ansible.module_utils.network.f5.common import exit_json + from ansible.module_utils.network.f5.common import fail_json + from ansible.module_utils.network.f5.icontrol import tmos_version + from ansible.module_utils.network.f5.icontrol import module_provisioned from ansible.module_utils.network.f5.ipaddress import is_valid_ip - try: - from ansible.module_utils.network.f5.common import iControlUnexpectedHTTPError - from f5.sdk_exception import LazyAttributesRequired - except ImportError: - HAS_F5SDK = False class Parameters(AnsibleF5Parameters): @@ -289,7 +302,8 @@ class Parameters(AnsibleF5Parameters): 'fallbackIpv6': 'fallback_ip', 'fallbackIp': 'fallback_ip', 'membersReference': 'members', - 'monitor': 'monitors' + 'monitor': 'monitors', + 'maxAnswersReturned': 'max_answers_returned', } updatables = [ @@ -300,6 +314,8 @@ class Parameters(AnsibleF5Parameters): 'monitors', 'preferred_lb_method', 'state', + 'max_answers_returned', + 'ttl', ] returnables = [ @@ -310,7 +326,10 @@ class Parameters(AnsibleF5Parameters): 'monitors', 'preferred_lb_method', 'enabled', - 'disabled' + 'disabled', + 'availability_requirements', + 'max_answers_returned', + 'ttl', ] api_attributes = [ @@ -324,34 +343,11 @@ class Parameters(AnsibleF5Parameters): 'loadBalancingMode', 'members', 'verifyMemberAvailability', - # The monitor attribute is not included here, because it can break the - # API calls to the device. If this bug is ever fixed, uncomment this code. - # - # monitor + 'monitor', + 'maxAnswersReturned', + 'ttl', ] - def to_return(self): - result = {} - for returnable in self.returnables: - result[returnable] = getattr(self, returnable) - result = self._filter_params(result) - return result - - @property - def collection(self): - type_map = dict( - a='a_s', - aaaa='aaaas', - cname='cnames', - mx='mxs', - naptr='naptrs', - srv='srvs' - ) - if self._values['type'] is None: - return None - wideip_type = self._values['type'] - return type_map[wideip_type] - @property def type(self): if self._values['type'] is None: @@ -449,7 +445,6 @@ class ApiParameters(Parameters): result = 'require {0} from {1} {{ {2} }}'.format(self.number_of_probes, self.number_of_probers, monitors) else: result = ' and '.join(monitors).strip() - return result @property @@ -608,15 +603,25 @@ class ModuleParameters(Parameters): class Changes(Parameters): - pass + def to_return(self): + result = {} + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + return result class UsableChanges(Changes): @property def monitors(self): - if self._values['monitors'] is None: + monitor_string = self._values['monitors'] + if monitor_string is None: return None - return self._values['monitors'] + if '{' in monitor_string and '}': + tmp = monitor_string.strip('}').split('{') + monitor = ''.join(tmp).rstrip() + return monitor + return monitor_string @property def members(self): @@ -646,6 +651,93 @@ class ReportableChanges(Changes): )) return results + @property + def monitors(self): + if self._values['monitors'] is None: + return [] + try: + result = re.findall(r'/\w+/[^\s}]+', self._values['monitors']) + result.sort() + return result + except Exception: + return self._values['monitors'] + + @property + def availability_requirement_type(self): + if self._values['monitors'] is None: + return None + if 'min ' in self._values['monitors']: + return 'at_least' + elif 'require ' in self._values['monitors']: + return 'require' + else: + return 'all' + + @property + def number_of_probes(self): + """Returns the probes value from the monitor string. + The monitor string for a Require monitor looks like this. + require 1 from 2 { /Common/tcp } + This method parses out the first of the numeric values. This values represents + the "probes" value that can be updated in the module. + Returns: + int: The probes value if found. None otherwise. + """ + if self._values['monitors'] is None: + return None + pattern = r'require\s+(?P\d+)\s+from' + matches = re.search(pattern, self._values['monitors']) + if matches is None: + return None + return int(matches.group('probes')) + + @property + def number_of_probers(self): + """Returns the probers value from the monitor string. + The monitor string for a Require monitor looks like this. + require 1 from 2 { /Common/tcp } + This method parses out the first of the numeric values. This values represents + the "probers" value that can be updated in the module. + Returns: + int: The probers value if found. None otherwise. + """ + if self._values['monitors'] is None: + return None + pattern = r'require\s+\d+\s+from\s+(?P\d+)\s+' + matches = re.search(pattern, self._values['monitors']) + if matches is None: + return None + return int(matches.group('probers')) + + @property + def at_least(self): + """Returns the 'at least' value from the monitor string. + The monitor string for a Require monitor looks like this. + min 1 of { /Common/gateway_icmp } + This method parses out the first of the numeric values. This values represents + the "at_least" value that can be updated in the module. + Returns: + int: The at_least value if found. None otherwise. + """ + if self._values['monitors'] is None: + return None + pattern = r'min\s+(?P\d+)\s+of\s+' + matches = re.search(pattern, self._values['monitors']) + if matches is None: + return None + return int(matches.group('least')) + + @property + def availability_requirements(self): + if self._values['monitors'] is None: + return None + result = dict() + result['type'] = self.availability_requirement_type + result['at_least'] = self.at_least + result['number_of_probers'] = self.number_of_probers + result['number_of_probes'] = self.number_of_probes + return result + class Difference(object): def __init__(self, want, have=None): @@ -701,7 +793,7 @@ class ModuleManager(object): self.client = kwargs.get('client', None) def exec_module(self): - if not self.gtm_provisioned(): + if not module_provisioned(self.client, 'gtm'): raise F5ModuleError( "GTM must be provisioned to use this module." ) @@ -718,20 +810,12 @@ class ModuleManager(object): return UntypedManager(**self.kwargs) def version_is_less_than_12(self): - version = self.client.api.tmos_version + version = tmos_version(self.client) if LooseVersion(version) < LooseVersion('12.0.0'): return True else: return False - def gtm_provisioned(self): - resource = self.client.api.tm.sys.dbs.db.load( - name='provisioned.cpu.gtm' - ) - if int(resource.value) == 0: - return False - return True - class BaseManager(object): def __init__(self, *args, **kwargs): @@ -772,13 +856,10 @@ class BaseManager(object): result = dict() state = self.want.state - try: - if state in ["present", "disabled"]: - changed = self.present() - elif state == "absent": - changed = self.absent() - except iControlUnexpectedHTTPError as e: - raise F5ModuleError(str(e)) + if state in ["present", "disabled"]: + changed = self.present() + elif state == "absent": + changed = self.absent() reportable = ReportableChanges(params=self.changes.to_return()) changes = reportable.to_return() @@ -850,36 +931,6 @@ class BaseManager(object): raise F5ModuleError("Failed to delete the GTM pool") return True - def update_monitors_on_device(self): - """Updates the monitors string on a virtual server - - There is a long-standing bug in GTM virtual servers where the monitor value - is a string that includes braces. These braces cause the REST API to panic and - fail to update or create any resources that have an "at_least" or "require" - set of availability_requirements. - - This method exists to do a tmsh command to cause the update to take place on - the device. - - Preferably, this method can be removed and the bug be fixed. The API should - be working, obviously, but the more concerning issue is if tmsh commands change - over time, breaking this method. - """ - command = 'tmsh modify gtm pool {0} /{1}/{2} monitor {3}'.format( - self.want.type, self.want.partition, self.want.name, self.want.monitors - ) - output = self.client.api.tm.util.bash.exec_cmd( - 'run', - utilCmdArgs='-c "{0}"'.format(command) - ) - try: - if hasattr(output, 'commandResult'): - if len(output.commandResult.strip()) > 0: - raise F5ModuleError(output.commandResult) - except (AttributeError, NameError, LazyAttributesRequired): - pass - return True - class TypedManager(BaseManager): def __init__(self, *args, **kwargs): @@ -906,112 +957,184 @@ class TypedManager(BaseManager): return super(TypedManager, self).present() def exists(self): - pools = self.client.api.tm.gtm.pools - collection = getattr(pools, self.want.collection) - resource = getattr(collection, self.want.type) - result = resource.exists( - name=self.want.name, - partition=self.want.partition + uri = "https://{0}:{1}/mgmt/tm/gtm/pool/{2}/{3}".format( + self.client.provider['server'], + self.client.provider['server_port'], + self.want.type, + transform_name(self.want.partition, self.want.name) ) - return result + resp = self.client.api.get(uri) + try: + response = resp.json() + except ValueError: + return False + if resp.status == 404 or 'code' in response and response['code'] == 404: + return False + return True def update_on_device(self): params = self.changes.api_params() - pools = self.client.api.tm.gtm.pools - collection = getattr(pools, self.want.collection) - resource = getattr(collection, self.want.type) - result = resource.load( - name=self.want.name, - partition=self.want.partition + uri = "https://{0}:{1}/mgmt/tm/gtm/pool/{2}/{3}".format( + self.client.provider['server'], + self.client.provider['server_port'], + self.want.type, + transform_name(self.want.partition, self.want.name) ) - if params: - result.modify(**params) - if self.want.monitors: - self.update_monitors_on_device() + resp = self.client.api.patch(uri, json=params) + 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) def read_current_from_device(self): - pools = self.client.api.tm.gtm.pools - collection = getattr(pools, self.want.collection) - resource = getattr(collection, self.want.type) - result = resource.load( - name=self.want.name, - partition=self.want.partition, - requests_params=dict( - params=dict( - expandSubcollections='true' - ) - ) + uri = "https://{0}:{1}/mgmt/tm/gtm/pool/{2}/{3}".format( + self.client.provider['server'], + self.client.provider['server_port'], + self.want.type, + transform_name(self.want.partition, self.want.name) ) - result = result.attrs - return ApiParameters(params=result) + + query = '?expandSubcollections=true' + 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'] == 400: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + return ApiParameters(params=response) def create_on_device(self): params = self.changes.api_params() - pools = self.client.api.tm.gtm.pools - collection = getattr(pools, self.want.collection) - resource = getattr(collection, self.want.type) - resource.create( - name=self.want.name, - partition=self.want.partition, - **params + params['name'] = self.want.name + params['partition'] = self.want.partition + uri = "https://{0}:{1}/mgmt/tm/gtm/pool/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + self.want.type ) - if self.want.monitors: - self.update_monitors_on_device() + 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, 403]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + return response['selfLink'] def remove_from_device(self): - pools = self.client.api.tm.gtm.pools - collection = getattr(pools, self.want.collection) - resource = getattr(collection, self.want.type) - resource = resource.load( - name=self.want.name, - partition=self.want.partition + uri = "https://{0}:{1}/mgmt/tm/gtm/pool/{2}/{3}".format( + self.client.provider['server'], + self.client.provider['server_port'], + self.want.type, + transform_name(self.want.partition, self.want.name) ) - if resource: - resource.delete() + response = self.client.api.delete(uri) + if response.status == 200: + return True + raise F5ModuleError(response.content) class UntypedManager(BaseManager): def exists(self): - result = self.client.api.tm.gtm.pools.pool.exists( - name=self.want.name, - partition=self.want.partition + uri = "https://{0}:{1}/mgmt/tm/gtm/pool/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + transform_name(self.want.partition, self.want.name) ) - return result + resp = self.client.api.get(uri) + try: + response = resp.json() + except ValueError: + return False + if resp.status == 404 or 'code' in response and response['code'] == 404: + return False + return True + + def create_on_device(self): + params = self.changes.api_params() + params['name'] = self.want.name + params['partition'] = self.want.partition + uri = "https://{0}:{1}/mgmt/tm/gtm/pool/".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, 403]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + return response['selfLink'] def update_on_device(self): params = self.changes.api_params() - resource = self.client.api.tm.gtm.pools.pool.load( - name=self.want.name, - partition=self.want.partition + uri = "https://{0}:{1}/mgmt/tm/gtm/pool/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + transform_name(self.want.partition, self.want.name) ) - resource.modify(**params) - if self.want.monitors: - self.update_monitors_on_device() + resp = self.client.api.patch(uri, json=params) + 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) def read_current_from_device(self): - resource = self.client.api.tm.gtm.pools.pool.load( - name=self.want.name, - partition=self.want.partition + uri = "https://{0}:{1}/mgmt/tm/gtm/pool/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + transform_name(self.want.partition, self.want.name) ) - result = resource.attrs - return ApiParameters(params=result) + resp = self.client.api.get(uri) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) - def create_on_device(self): - params = self.changes.api_params() - self.client.api.tm.gtm.pools.pool.create( - name=self.want.name, - partition=self.want.partition, - **params - ) - if self.want.monitors: - self.update_monitors_on_device() + 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) def remove_from_device(self): - resource = self.client.api.tm.gtm.pools.pool.load( - name=self.want.name, - partition=self.want.partition + uri = "https://{0}:{1}/mgmt/tm/gtm/pool/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + transform_name(self.want.partition, self.want.name) ) - resource.delete() + response = self.client.api.delete(uri) + if response.status == 200: + return True + raise F5ModuleError(response.content) class ArgumentSpec(object): @@ -1088,6 +1211,8 @@ class ArgumentSpec(object): ] ), monitors=dict(type='list'), + max_answers_returned=dict(type='int'), + ttl=dict(type='int') ) self.argument_spec = {} self.argument_spec.update(f5_argument_spec) @@ -1105,20 +1230,18 @@ def main(): module = AnsibleModule( argument_spec=spec.argument_spec, supports_check_mode=spec.supports_check_mode, - required_if=spec.required_if ) - if not HAS_F5SDK: - module.fail_json(msg="The python f5-sdk module is required") + + client = F5RestClient(**module.params) try: - client = F5Client(**module.params) mm = ModuleManager(module=module, client=client) results = mm.exec_module() cleanup_tokens(client) - module.exit_json(**results) + exit_json(module, results, client) except F5ModuleError as ex: cleanup_tokens(client) - module.fail_json(msg=str(ex)) + fail_json(module, ex, client) if __name__ == '__main__': diff --git a/test/units/modules/network/f5/test_bigip_gtm_pool.py b/test/units/modules/network/f5/test_bigip_gtm_pool.py index e995420e0eb..1f338127559 100644 --- a/test/units/modules/network/f5/test_bigip_gtm_pool.py +++ b/test/units/modules/network/f5/test_bigip_gtm_pool.py @@ -14,9 +14,6 @@ from nose.plugins.skip import SkipTest if sys.version_info < (2, 7): raise SkipTest("F5 Ansible modules require Python >= 2.7") -from units.compat import unittest -from units.compat.mock import Mock -from units.compat.mock import patch from ansible.module_utils.basic import AnsibleModule try: @@ -26,9 +23,13 @@ try: from library.modules.bigip_gtm_pool import ArgumentSpec from library.modules.bigip_gtm_pool import UntypedManager from library.modules.bigip_gtm_pool import TypedManager - 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 + + # 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: try: from ansible.modules.network.f5.bigip_gtm_pool import ApiParameters @@ -37,8 +38,12 @@ except ImportError: from ansible.modules.network.f5.bigip_gtm_pool import ArgumentSpec from ansible.modules.network.f5.bigip_gtm_pool import UntypedManager from ansible.modules.network.f5.bigip_gtm_pool import TypedManager - from ansible.module_utils.network.f5.common import F5ModuleError - from ansible.module_utils.network.f5.common import iControlUnexpectedHTTPError + + # 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 except ImportError: raise SkipTest("F5 Ansible modules require the f5-sdk Python library") @@ -126,6 +131,18 @@ class TestUntypedManager(unittest.TestCase): def setUp(self): self.spec = ArgumentSpec() + try: + self.p1 = patch('library.modules.bigip_gtm_pool.module_provisioned') + self.m1 = self.p1.start() + self.m1.return_value = True + except Exception: + self.p1 = patch('ansible.modules.network.f5.bigip_gtm_pool.module_provisioned') + self.m1 = self.p1.start() + self.m1.return_value = True + + def tearDown(self): + self.p1.stop() + def test_create_pool(self, *args): set_module_args(dict( name='foo', @@ -150,6 +167,7 @@ class TestUntypedManager(unittest.TestCase): mm.version_is_less_than_12 = Mock(return_value=True) mm.get_manager = Mock(return_value=tm) mm.gtm_provisioned = Mock(return_value=True) + mm.module_provisioned = Mock(return_value=True) results = mm.exec_module() @@ -185,6 +203,7 @@ class TestUntypedManager(unittest.TestCase): mm.version_is_less_than_12 = Mock(return_value=True) mm.get_manager = Mock(return_value=tm) mm.gtm_provisioned = Mock(return_value=True) + mm.module_provisioned = Mock(return_value=True) results = mm.exec_module() @@ -217,6 +236,7 @@ class TestUntypedManager(unittest.TestCase): mm.version_is_less_than_12 = Mock(return_value=True) mm.get_manager = Mock(return_value=tm) mm.gtm_provisioned = Mock(return_value=True) + mm.module_provisioned = Mock(return_value=True) results = mm.exec_module() @@ -228,6 +248,18 @@ class TestTypedManager(unittest.TestCase): def setUp(self): self.spec = ArgumentSpec() + try: + self.p1 = patch('library.modules.bigip_gtm_pool.module_provisioned') + self.m1 = self.p1.start() + self.m1.return_value = True + except Exception: + self.p1 = patch('ansible.modules.network.f5.bigip_gtm_pool.module_provisioned') + self.m1 = self.p1.start() + self.m1.return_value = True + + def tearDown(self): + self.p1.stop() + def test_create_pool(self, *args): set_module_args(dict( name='foo', @@ -253,6 +285,7 @@ class TestTypedManager(unittest.TestCase): mm.version_is_less_than_12 = Mock(return_value=False) mm.get_manager = Mock(return_value=tm) mm.gtm_provisioned = Mock(return_value=True) + mm.module_provisioned = Mock(return_value=True) results = mm.exec_module() @@ -289,6 +322,7 @@ class TestTypedManager(unittest.TestCase): mm.version_is_less_than_12 = Mock(return_value=False) mm.get_manager = Mock(return_value=tm) mm.gtm_provisioned = Mock(return_value=True) + mm.module_provisioned = Mock(return_value=True) results = mm.exec_module() @@ -322,6 +356,7 @@ class TestTypedManager(unittest.TestCase): mm.version_is_less_than_12 = Mock(return_value=False) mm.get_manager = Mock(return_value=tm) mm.gtm_provisioned = Mock(return_value=True) + mm.module_provisioned = Mock(return_value=True) results = mm.exec_module()