diff --git a/lib/ansible/modules/network/f5/bigip_pool_member.py b/lib/ansible/modules/network/f5/bigip_pool_member.py index 88429f4ebdd..8972592fb43 100644 --- a/lib/ansible/modules/network/f5/bigip_pool_member.py +++ b/lib/ansible/modules/network/f5/bigip_pool_member.py @@ -1,8 +1,8 @@ #!/usr/bin/python # -*- coding: utf-8 -*- # -# Copyright (c) 2017 F5 Networks Inc. -# Copyright (c) 2013 Matt Hite +# Copyright: (c) 2017, F5 Networks Inc. +# Copyright: (c) 2013, Matt Hite # 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 @@ -130,18 +130,48 @@ options: default: yes type: bool version_added: 2.6 + monitors: + description: + - Specifies the health monitors that the system currently uses to monitor + this resource. + version_added: 2.8 + availability_requirements: + description: + - Specifies, if you activate more than one health monitor, the number of health + monitors that must receive successful responses in order for the link to be + considered available. + suboptions: + type: + description: + - Monitor rule type when C(monitors) is specified. + - When creating a new pool, if this value is not specified, the default of + 'all' will be used. + choices: ['all', 'at_least'] + at_least: + description: + - Specifies the minimum number of active health monitors that must be successful + before the link is considered up. + - This parameter is only relevant when a C(type) of C(at_least) is used. + - This parameter will be ignored if a type of C(all) is used. + version_added: 2.8 + ip_encapsulation: + description: + - Specifies the IP encapsulation using either IPIP (IP encapsulation within IP, + RFC 2003) or GRE (Generic Router Encapsulation, RFC 2784) on outbound packets + (from BIG-IP system to server-pool member). + - When C(none), disables IP encapsulation. + - When C(inherit), inherits IP encapsulation setting from the member's pool. + - When any other value, Options are None, Inherit from Pool, and Member Specific. + version_added: 2.8 extends_documentation_fragment: f5 author: - Tim Rupp (@caphrim007) + - Wojciech Wypior (@wojtek0806) ''' EXAMPLES = ''' - name: Add pool member bigip_pool_member: - server: lb.mydomain.com - user: admin - password: secret - state: present pool: my-pool partition: Common host: "{{ ansible_default_ipv4['address'] }}" @@ -150,57 +180,64 @@ EXAMPLES = ''' connection_limit: 100 rate_limit: 50 ratio: 2 + provider: + server: lb.mydomain.com + user: admin + password: secret delegate_to: localhost - name: Modify pool member ratio and description bigip_pool_member: - server: lb.mydomain.com - user: admin - password: secret - state: present pool: my-pool partition: Common host: "{{ ansible_default_ipv4['address'] }}" port: 80 ratio: 1 description: nginx server + provider: + server: lb.mydomain.com + user: admin + password: secret delegate_to: localhost - name: Remove pool member from pool bigip_pool_member: - server: lb.mydomain.com - user: admin - password: secret state: absent pool: my-pool partition: Common host: "{{ ansible_default_ipv4['address'] }}" port: 80 + provider: + server: lb.mydomain.com + user: admin + password: secret delegate_to: localhost - name: Force pool member offline bigip_pool_member: - server: lb.mydomain.com - user: admin - password: secret state: forced_offline pool: my-pool partition: Common host: "{{ ansible_default_ipv4['address'] }}" port: 80 + provider: + server: lb.mydomain.com + user: admin + password: secret delegate_to: localhost - name: Create members with priority groups bigip_pool_member: - server: lb.mydomain.com - user: admin - password: secret pool: my-pool partition: Common host: "{{ item.address }}" name: "{{ item.name }}" priority_group: "{{ item.priority_group }}" port: 80 + provider: + server: lb.mydomain.com + user: admin + password: secret delegate_to: localhost loop: - host: 1.1.1.1 @@ -258,8 +295,16 @@ address: returned: changed type: string sample: 1.2.3.4 +monitors: + description: The new list of monitors for the resource. + returned: changed + type: list + sample: ['/Common/monitor1', '/Common/monitor2'] ''' +import os +import re + from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import env_fallback @@ -274,6 +319,8 @@ try: from library.module_utils.network.f5.common import fail_json from library.module_utils.network.f5.common import f5_argument_spec from library.module_utils.network.f5.common import is_valid_hostname + from library.module_utils.network.f5.common import flatten_boolean + from library.module_utils.network.f5.compare import cmp_str_with_none from library.module_utils.network.f5.ipaddress import is_valid_ip from library.module_utils.network.f5.ipaddress import validate_ip_v6_address except ImportError: @@ -288,6 +335,8 @@ except ImportError: from ansible.module_utils.network.f5.common import f5_argument_spec from ansible.module_utils.network.f5.common import is_valid_hostname 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.compare import cmp_str_with_none from ansible.module_utils.network.f5.ipaddress import is_valid_ip from ansible.module_utils.network.f5.ipaddress import validate_ip_v6_address @@ -297,21 +346,57 @@ class Parameters(AnsibleF5Parameters): 'rateLimit': 'rate_limit', 'connectionLimit': 'connection_limit', 'priorityGroup': 'priority_group', + 'monitor': 'monitors', + 'inheritProfile': 'inherit_profile', + 'profiles': 'ip_encapsulation', } api_attributes = [ - 'rateLimit', 'connectionLimit', 'description', 'ratio', 'priorityGroup', - 'address', 'fqdn', 'session', 'state', + 'rateLimit', + 'connectionLimit', + 'description', + 'ratio', + 'priorityGroup', + 'address', + 'fqdn', + 'session', + 'state', + 'monitor', + + # These two settings are for IP Encapsulation + 'inheritProfile', + 'profiles', ] returnables = [ - 'rate_limit', 'connection_limit', 'description', 'ratio', 'priority_group', - 'fqdn_auto_populate', 'session', 'state', 'fqdn', 'address', + 'rate_limit', + 'connection_limit', + 'description', + 'ratio', + 'priority_group', + 'fqdn_auto_populate', + 'session', + 'state', + 'fqdn', + 'address', + 'monitors', + + # IP Encapsulation related + 'inherit_profile', + 'ip_encapsulation', ] updatables = [ - 'rate_limit', 'connection_limit', 'description', 'ratio', 'priority_group', - 'fqdn_auto_populate', 'state', + 'rate_limit', + 'connection_limit', + 'description', + 'ratio', + 'priority_group', + 'fqdn_auto_populate', + 'state', + 'monitors', + 'inherit_profile', + 'ip_encapsulation', ] @@ -379,7 +464,8 @@ class ModuleParameters(Parameters): return None elif self._values['address'] == 'any6': return 'any6' - if is_valid_ip(self._values['address']): + address = self._values['address'].split('%')[0] + if is_valid_ip(address): return self._values['address'] raise F5ModuleError( "The specified 'address' value is not a valid IP address." @@ -391,8 +477,107 @@ class ModuleParameters(Parameters): return 'present' return self._values['state'] + @property + def monitors_list(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 monitors(self): + if self._values['monitors'] is None: + return None + if len(self._values['monitors']) == 1 and self._values['monitors'][0] in ['', 'none']: + return 'default' + monitors = [fq_name(self.partition, x) for x in self.monitors_list] + if self.availability_requirement_type == 'at_least': + if self.at_least > len(self.monitors_list): + raise F5ModuleError( + "The 'at_least' value must not exceed the number of 'monitors'." + ) + monitors = ' '.join(monitors) + result = 'min {0} of {{ {1} }}'.format(self.at_least, monitors) + else: + result = ' and '.join(monitors).strip() + return result + + @property + def availability_requirement_type(self): + if self._values['availability_requirements'] is None: + return None + return self._values['availability_requirements']['type'] + + @property + def at_least(self): + return self._get_availability_value('at_least') + + @property + def ip_encapsulation(self): + if self._values['ip_encapsulation'] is None: + return None + if self._values['ip_encapsulation'] == 'inherit': + return 'inherit' + if self._values['ip_encapsulation'] in ['', 'none']: + return '' + return fq_name(self.partition, self._values['ip_encapsulation']) + + def _get_availability_value(self, type): + if self._values['availability_requirements'] is None: + return None + if self._values['availability_requirements'][type] is None: + return None + return int(self._values['availability_requirements'][type]) + class ApiParameters(Parameters): + @property + def ip_encapsulation(self): + """Returns a simple name for the tunnel. + + The API stores the data like so + + "profiles": [ + { + "name": "gre", + "partition": "Common", + "nameReference": { + "link": "https://localhost/mgmt/tm/net/tunnels/gre/~Common~gre?ver=13.1.0.7" + } + } + ] + + This method returns that data as a simple profile name. For instance, + + /Common/gre + + This allows us to do comparisons of it in the Difference class and then, + as needed, translate it back to the more complex form in the UsableChanges + class. + + Returns: + string: The simple form representation of the tunnel + """ + if self._values['ip_encapsulation'] is None and self.inherit_profile == 'yes': + return 'inherit' + if self._values['ip_encapsulation'] is None and self.inherit_profile == 'no': + return '' + if self._values['ip_encapsulation'] is None: + return None + + # There can be only one + tunnel = self._values['ip_encapsulation'][0] + + return fq_name(tunnel['partition'], tunnel['name']) + + @property + def inherit_profile(self): + return flatten_boolean(self._values['inherit_profile']) + @property def allow(self): if self._values['allow'] is None: @@ -416,13 +601,75 @@ class ApiParameters(Parameters): def state(self): if self._values['state'] in ['user-up', 'unchecked', 'fqdn-up-no-addr', 'fqdn-up'] and self._values['session'] in ['user-enabled']: return 'present' - elif self._values['state'] in ['down', 'up'] and self._values['session'] == 'monitor-enabled': + elif self._values['state'] in ['down', 'up', 'checking'] and self._values['session'] == 'monitor-enabled': + # monitor-enabled + checking: + # Monitor is checking to see state of pool member. For instance, + # whether it is up or down + # + # monitor-enabled + down: + # Monitor returned and determined that pool member is down. + # + # monitor-enabled + up + # Monitor returned and determined that pool member is up. return 'present' elif self._values['state'] in ['user-down'] and self._values['session'] in ['user-disabled']: return 'forced_offline' else: return 'disabled' + @property + def availability_requirement_type(self): + if self._values['monitors'] is None: + return None + if 'min ' in self._values['monitors']: + return 'at_least' + else: + return 'all' + + @property + def monitors_list(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 monitors(self): + if self._values['monitors'] is None: + return None + if self._values['monitors'] == 'default': + return 'default' + monitors = [fq_name(self.partition, x) for x in self.monitors_list] + if self.availability_requirement_type == 'at_least': + monitors = ' '.join(monitors) + result = 'min {0} of {{ {1} }}'.format(self.at_least, monitors) + else: + result = ' and '.join(monitors).strip() + + return result + + @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 matches.group('least') + class NodeApiParameters(Parameters): pass @@ -441,7 +688,16 @@ class Changes(Parameters): class UsableChanges(Changes): - pass + @property + def monitors(self): + monitor_string = self._values['monitors'] + if monitor_string is None: + return None + if '{' in monitor_string and '}': + tmp = monitor_string.strip('}').split('{') + monitor = ''.join(tmp).rstrip() + return monitor + return monitor_string class ReportableChanges(Changes): @@ -473,13 +729,60 @@ class ReportableChanges(Changes): def state(self): if self._values['state'] in ['user-up', 'unchecked', 'fqdn-up-no-addr', 'fqdn-up'] and self._values['session'] in ['user-enabled']: return 'present' - elif self._values['state'] in ['down', 'up'] and self._values['session'] == 'monitor-enabled': + elif self._values['state'] in ['down', 'up', 'checking'] and self._values['session'] == 'monitor-enabled': return 'present' elif self._values['state'] in ['user-down'] and self._values['session'] in ['user-disabled']: return 'forced_offline' else: return 'disabled' + @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' + else: + return 'all' + + @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 + return result + class Difference(object): def __init__(self, want, have=None): @@ -522,6 +825,47 @@ class Difference(object): 'session': 'user-enabled' } + @property + def monitors(self): + if self.want.monitors is None: + return None + if self.want.monitors == 'default' and self.have.monitors == 'default': + return None + if self.want.monitors == 'default' and self.have.monitors is None: + return None + if self.want.monitors == 'default' and len(self.have.monitors) > 0: + return 'default' + if self.have.monitors is None: + return self.want.monitors + if self.have.monitors != self.want.monitors: + return self.want.monitors + + @property + def ip_encapsulation(self): + result = cmp_str_with_none(self.want.ip_encapsulation, self.have.ip_encapsulation) + if result is None: + return None + if result == 'inherit': + return dict( + inherit_profile='enabled', + ip_encapsulation=[] + ) + elif result in ['', 'none']: + return dict( + inherit_profile='disabled', + ip_encapsulation=[] + ) + else: + return dict( + inherit_profile='disabled', + ip_encapsulation=[ + dict( + name=os.path.basename(result).strip('/'), + partition=os.path.dirname(result) + ) + ] + ) + class ModuleManager(object): def __init__(self, *args, **kwargs): @@ -601,7 +945,7 @@ class ModuleManager(object): uri = "https://{0}:{1}/mgmt/tm/ltm/pool/{2}/members/{3}".format( self.client.provider['server'], self.client.provider['server_port'], - transform_name(self.want.partition, self.want.pool), + transform_name(name=fq_name(self.want.partition, self.want.pool)), transform_name(self.want.partition, self.want.full_name) ) resp = self.client.api.get(uri) @@ -617,7 +961,7 @@ class ModuleManager(object): uri = "https://{0}:{1}/mgmt/tm/ltm/pool/{2}".format( self.client.provider['server'], self.client.provider['server_port'], - transform_name(self.want.partition, self.want.pool) + transform_name(name=fq_name(self.want.partition, self.want.pool)) ) resp = self.client.api.get(uri) try: @@ -719,9 +1063,25 @@ class ModuleManager(object): def create(self): if self.want.reuse_nodes: self._update_address_with_existing_nodes() + if self.want.name and not any(x for x in [self.want.address, self.want.fqdn_name]): self._set_host_by_name() + if self.want.ip_encapsulation == '': + self.changes.update({'inherit_profile': 'enabled'}) + self.changes.update({'profiles': []}) + elif self.want.ip_encapsulation: + # Read the current list of tunnels so that IP encapsulation + # checking can take place. + tunnels_gre = self.read_current_tunnels_from_device('gre') + tunnels_ipip = self.read_current_tunnels_from_device('ipip') + tunnels = tunnels_gre + tunnels_ipip + if self.want.ip_encapsulation not in tunnels: + raise F5ModuleError( + "The specified 'ip_encapsulation' tunnel was not found on the system." + ) + self.changes.update({'inherit_profile': 'disabled'}) + self._update_api_state_attributes() self._set_changed_options() if self.module.check_mode: @@ -736,7 +1096,7 @@ class ModuleManager(object): uri = "https://{0}:{1}/mgmt/tm/ltm/pool/{2}/members".format( self.client.provider['server'], self.client.provider['server_port'], - transform_name(self.want.partition, self.want.pool), + transform_name(name=fq_name(self.want.partition, self.want.pool)), ) resp = self.client.api.post(uri, json=params) @@ -757,7 +1117,7 @@ class ModuleManager(object): uri = "https://{0}:{1}/mgmt/tm/ltm/pool/{2}/members/{3}".format( self.client.provider['server'], self.client.provider['server_port'], - transform_name(self.want.partition, self.want.pool), + transform_name(name=fq_name(self.want.partition, self.want.pool)), transform_name(self.want.partition, self.want.full_name) ) @@ -784,7 +1144,7 @@ class ModuleManager(object): uri = "https://{0}:{1}/mgmt/tm/ltm/pool/{2}/members/{3}".format( self.client.provider['server'], self.client.provider['server_port'], - transform_name(self.want.partition, self.want.pool), + transform_name(name=fq_name(self.want.partition, self.want.pool)), transform_name(self.want.partition, self.want.full_name) ) @@ -808,7 +1168,7 @@ class ModuleManager(object): uri = "https://{0}:{1}/mgmt/tm/ltm/pool/{2}/members/{3}".format( self.client.provider['server'], self.client.provider['server_port'], - transform_name(self.want.partition, self.want.pool), + transform_name(name=fq_name(self.want.partition, self.want.pool)), transform_name(self.want.partition, self.want.full_name) ) @@ -823,6 +1183,13 @@ class ModuleManager(object): raise F5ModuleError(response['message']) else: raise F5ModuleError(resp.content) + + # Read the current list of tunnels so that IP encapsulation + # checking can take place. + tunnels_gre = self.read_current_tunnels_from_device('gre') + tunnels_ipip = self.read_current_tunnels_from_device('ipip') + response['tunnels'] = tunnels_gre + tunnels_ipip + return ApiParameters(params=response) def read_current_node_from_device(self, node): @@ -844,6 +1211,27 @@ class ModuleManager(object): raise F5ModuleError(resp.content) return NodeApiParameters(params=response) + def read_current_tunnels_from_device(self, tunnel_type): + uri = "https://{0}:{1}/mgmt/tm/net/tunnels/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + tunnel_type + ) + 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 'items' not in response: + return [] + return [x['fullPath'] for x in response['items']] + class ArgumentSpec(object): def __init__(self): @@ -872,6 +1260,21 @@ class ArgumentSpec(object): ), fqdn_auto_populate=dict(type='bool'), reuse_nodes=dict(type='bool', default=True), + availability_requirements=dict( + type='dict', + options=dict( + type=dict( + choices=['all', 'at_least'], + required=True + ), + at_least=dict(type='int'), + ), + required_if=[ + ['type', 'at_least', ['at_least']], + ] + ), + monitors=dict(type='list'), + ip_encapsulation=dict(), ) self.argument_spec = {} self.argument_spec.update(f5_argument_spec) diff --git a/test/units/modules/network/f5/test_bigip_pool_member.py b/test/units/modules/network/f5/test_bigip_pool_member.py index 2858897fe8b..ccc42e89740 100644 --- a/test/units/modules/network/f5/test_bigip_pool_member.py +++ b/test/units/modules/network/f5/test_bigip_pool_member.py @@ -8,16 +8,12 @@ __metaclass__ = type import os import json -import pytest import sys 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 +22,13 @@ try: from library.modules.bigip_pool_member import NodeApiParameters from library.modules.bigip_pool_member import ModuleManager from library.modules.bigip_pool_member 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 + + # 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_pool_member import ModuleParameters @@ -36,8 +36,12 @@ except ImportError: from ansible.modules.network.f5.bigip_pool_member import NodeApiParameters from ansible.modules.network.f5.bigip_pool_member import ModuleManager from ansible.modules.network.f5.bigip_pool_member import ArgumentSpec - 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")