diff --git a/lib/ansible/modules/network/f5/bigip_pool.py b/lib/ansible/modules/network/f5/bigip_pool.py index 14c03352801..f628d326778 100644 --- a/lib/ansible/modules/network/f5/bigip_pool.py +++ b/lib/ansible/modules/network/f5/bigip_pool.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- - -# (c) 2013, Matt Hite +# +# Copyright 2017 F5 Networks Inc. # # This file is part of Ansible # @@ -18,154 +18,106 @@ # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . -ANSIBLE_METADATA = {'metadata_version': '1.0', - 'status': ['preview'], - 'supported_by': 'community'} +ANSIBLE_METADATA = { + 'status': ['preview'], + 'supported_by': 'community', + 'metadata_version': '1.0' +} DOCUMENTATION = ''' --- module: bigip_pool -short_description: "Manages F5 BIG-IP LTM pools" +short_description: Manages F5 BIG-IP LTM pools. description: - - Manages F5 BIG-IP LTM pools via iControl SOAP API + - Manages F5 BIG-IP LTM pools via iControl REST API. version_added: 1.2 author: - - Matt Hite (@mhite) - Tim Rupp (@caphrim007) + - Wojciech Wypior (@wojtek0806) notes: - - Requires BIG-IP software version >= 11 - - F5 developed module 'bigsuds' required (see http://devcentral.f5.com) - - Best run as a local_action in your playbook + - Requires BIG-IP software version >= 11. + - F5 developed module 'F5-SDK' required (https://github.com/F5Networks/f5-common-python). + - Best run as a local_action in your playbook. requirements: - - bigsuds + - f5-sdk options: description: description: - Specifies descriptive text that identifies the pool. - required: false version_added: "2.3" - state: - description: - - Pool/pool member state - required: false - default: present - choices: - - present - - absent - aliases: [] name: description: - Pool name - required: true - default: null - choices: [] + required: True aliases: - pool - partition: - description: - - Partition of pool/pool member - required: false - default: 'Common' - choices: [] - aliases: [] lb_method: description: - - Load balancing method + - Load balancing method. When creating a new pool, if this value is not + specified, the default of C(round-robin) will be used. version_added: "1.3" - required: False - default: 'round_robin' choices: - - round_robin - - ratio_member - - least_connection_member - - observed_member - - predictive_member - - ratio_node_address - - least_connection_node_address - - fastest_node_address - - observed_node_address - - predictive_node_address - - dynamic_ratio - - fastest_app_response - - least_sessions - - dynamic_ratio_member - - l3_addr - - weighted_least_connection_member - - weighted_least_connection_node_address - - ratio_session - - ratio_least_connection_member - - ratio_least_connection_node_address - aliases: [] + - dynamic-ratio-member + - dynamic-ratio-node + - fastest-app-response + - fastest-node + - least-connections-member + - least-connections-node + - least-sessions + - observed-member + - observed-node + - predictive-member + - predictive-node + - ratio-least-connections-member + - ratio-least-connections-node + - ratio-member + - ratio-node + - ratio-session + - round-robin + - weighted-least-connections-member + - weighted-least-connections-nod monitor_type: description: - - Monitor rule type when monitors > 1 + - Monitor rule type when C(monitors) > 1. version_added: "1.3" - required: False - default: null choices: ['and_list', 'm_of_n'] - aliases: [] quorum: description: - - Monitor quorum value when monitor_type is m_of_n + - Monitor quorum value when C(monitor_type) is C(m_of_n). version_added: "1.3" - required: False - default: null - choices: [] - aliases: [] monitors: description: - - Monitor template name list. Always use the full path to the monitor. + - Monitor template name list. If the partition is not provided as part of + the monitor name, then the C(partition) option will be used instead. version_added: "1.3" - required: False - default: null - choices: [] - aliases: [] slow_ramp_time: description: - Sets the ramp-up time (in seconds) to gradually ramp up the load on - newly added or freshly detected up pool members + newly added or freshly detected up pool members. version_added: "1.3" - required: False - default: null - choices: [] - aliases: [] reselect_tries: description: - Sets the number of times the system tries to contact a pool member - after a passive failure + after a passive failure. version_added: "2.2" - required: False - default: null - choices: [] - aliases: [] service_down_action: description: - - Sets the action to take when node goes down in pool + - Sets the action to take when node goes down in pool. version_added: "1.3" - required: False - default: null choices: - none - reset - drop - reselect - aliases: [] host: description: - - "Pool member IP" - required: False - default: null - choices: [] + - Pool member IP. aliases: - address port: description: - - Pool member port - required: False - default: null - choices: [] - aliases: [] + - Pool member port. extends_documentation_fragment: f5 ''' @@ -191,6 +143,7 @@ EXAMPLES = ''' name: "my-pool" partition: "Common" lb_method: "round_robin" + delegate_to: localhost - name: Add pool member bigip_pool: @@ -202,6 +155,7 @@ EXAMPLES = ''' partition: "Common" host: "{{ ansible_default_ipv4['address'] }}" port: 80 + delegate_to: localhost - name: Remove pool member from pool bigip_pool: @@ -213,6 +167,7 @@ EXAMPLES = ''' partition: "Common" host: "{{ ansible_default_ipv4['address'] }}" port: 80 + delegate_to: localhost - name: Delete pool bigip_pool: @@ -222,370 +177,564 @@ EXAMPLES = ''' state: "absent" name: "my-pool" partition: "Common" + delegate_to: localhost ''' RETURN = ''' +monitor_type: + description: The contact that was set on the datacenter. + returned: changed + type: string + sample: "admin@root.local" +quorum: + description: The quorum that was set on the pool + returned: changed + type: int + sample: 2 +monitors: + description: Monitors set on the pool. + returned: changed + type: list + sample: ['/Common/http', '/Common/gateway_icmp'] +service_down_action: + description: Service down action that is set on the pool. + returned: changed + type: string + sample: "reset" +description: + description: Description set on the pool. + returned: changed + type: string + sample: "Pool of web servers" +lb_method: + description: The LB method set for the pool. + returned: changed + type: string + sample: "round-robin" +host: + description: IP of pool member included in pool. + returned: changed + type: string + sample: "10.10.10.10" +port: + description: Port of pool member included in pool. + returned: changed + type: int + sample: 80 +slow_ramp_time: + description: The new value that is set for the slow ramp-up time. + returned: changed + type: int + sample: 500 +reselect_tries: + description: The new value that is set for the number of tries to contact member + returned: changed + type: int + sample: 10 ''' - -def pool_exists(api, pool): - # hack to determine if pool exists - result = False - try: - api.LocalLB.Pool.get_object_status(pool_names=[pool]) - result = True - except bigsuds.OperationFailed as e: - if "was not found" in str(e): - result = False +import re +import os +from netaddr import IPAddress, AddrFormatError +from ansible.module_utils.f5_utils import ( + AnsibleF5Client, + AnsibleF5Parameters, + HAS_F5SDK, + F5ModuleError, + iControlUnexpectedHTTPError +) + + +class Parameters(AnsibleF5Parameters): + api_map = { + 'loadBalancingMode': 'lb_method', + 'slowRampTime': 'slow_ramp_time', + 'reselectTries': 'reselect_tries', + 'serviceDownAction': 'service_down_action' + } + + updatables = [ + 'monitor_type', 'quorum', 'monitors', 'service_down_action', + 'description', 'lb_method', 'slow_ramp_time', 'reselect_tries', + 'host', 'port' + ] + + returnables = [ + 'monitor_type', 'quorum', 'monitors', 'service_down_action', + 'description', 'lb_method', 'host', 'port', 'slow_ramp_time', + 'reselect_tries', 'monitor', 'member_name', 'name', 'partition' + ] + + api_attributes = [ + 'description', 'name', 'loadBalancingMode', 'monitor', 'slowRampTime', + 'reselectTries', 'serviceDownAction' + ] + + def __init__(self, params=None): + super(Parameters, self).__init__(params) + self._values['__warnings'] = [] + + @property + def lb_method(self): + lb_map = { + 'ratio_node_address': 'ratio-node', + 'dynamic_ratio': 'dynamic-ratio-node', + 'least_connection_member': 'least-connections-member', + 'least_connection_node_address': 'least-connections-node', + 'fastest_node_address': 'fastest-node', + 'observed_node_address': 'observed-node', + 'predictive_node_address': 'predictive-node', + 'weighted_least_connection_member': 'weighted-least-connections-member', + 'weighted_least_connection_node_address': 'weighted-least-connections-node', + 'ratio_least_connection_member': 'ratio-least-connections-member', + 'ratio_least_connection_node_address': 'ratio-least-connections-node' + } + lb_method = self._values['lb_method'] + if lb_method is None: + return None + + spec = ArgumentSpec() + if lb_method in spec.lb_choice_removed: + raise F5ModuleError( + "The provided lb_method is not supported" + ) + if lb_method in spec.lb_choice_deprecated: + self._values['__warnings'].append( + dict( + msg="The provided lb_method '{0}' is deprecated".format(lb_method), + version='2.4' + ) + ) + lb_method = lb_map.get(lb_method, lb_method.replace('_', '-')) + try: + assert lb_method in spec.lb_choice + except AssertionError: + raise F5ModuleError('Provided lb_method is unknown') + return lb_method + + @property + def monitors(self): + monitors = list() + monitor_list = self._values['monitors'] + monitor_type = self._values['monitor_type'] + error1 = "The 'monitor_type' parameter cannot be empty when " \ + "'monitors' parameter is specified." + error2 = "The 'monitor' parameter cannot be empty when " \ + "'monitor_type' parameter is specified" + if monitor_list is not None and monitor_type is None: + raise F5ModuleError(error1) + elif monitor_list is None and monitor_type is not None: + raise F5ModuleError(error2) + elif monitor_list is None: + return None + + for m in monitor_list: + if re.match(r'\/\w+\/\w+', m): + m = '/{0}/{1}'.format(self.partition, os.path.basename(m)) + elif re.match(r'\w+', m): + m = '/{0}/{1}'.format(self.partition, m) + else: + raise F5ModuleError( + "Unknown monitor format '{0}'".format(m) + ) + monitors.append(m) + + return monitors + + @property + def quorum(self): + value = self._values['quorum'] + error = "Quorum value must be specified with monitor_type 'm_of_n'." + if self._values['monitor_type'] == 'm_of_n' and value is None: + raise F5ModuleError(error) + return value + + @property + def monitor(self): + monitors = self.monitors + monitor_type = self._values['monitor_type'] + quorum = self.quorum + + if monitors is None: + return None + + if monitor_type == 'and_list': + and_list = list() + for m in monitors: + if monitors.index(m) == 0: + and_list.append(m) + else: + and_list.append('and') + and_list.append(m) + result = ' '.join(and_list) else: - # genuine exception - raise - return result - - -def create_pool(api, pool, lb_method): - # create requires lb_method but we don't want to default - # to a value on subsequent runs - if not lb_method: - lb_method = 'round_robin' - lb_method = "LB_METHOD_%s" % lb_method.strip().upper() - api.LocalLB.Pool.create_v2(pool_names=[pool], lb_methods=[lb_method], - members=[[]]) - - -def remove_pool(api, pool): - api.LocalLB.Pool.delete_pool(pool_names=[pool]) - - -def get_lb_method(api, pool): - lb_method = api.LocalLB.Pool.get_lb_method(pool_names=[pool])[0] - lb_method = lb_method.strip().replace('LB_METHOD_', '').lower() - return lb_method - - -def set_lb_method(api, pool, lb_method): - lb_method = "LB_METHOD_%s" % lb_method.strip().upper() - api.LocalLB.Pool.set_lb_method(pool_names=[pool], lb_methods=[lb_method]) - - -def get_monitors(api, pool): - result = api.LocalLB.Pool.get_monitor_association(pool_names=[pool])[0]['monitor_rule'] - monitor_type = result['type'].split("MONITOR_RULE_TYPE_")[-1].lower() - quorum = result['quorum'] - monitor_templates = result['monitor_templates'] - return (monitor_type, quorum, monitor_templates) - - -def set_monitors(api, pool, monitor_type, quorum, monitor_templates): - monitor_type = "MONITOR_RULE_TYPE_%s" % monitor_type.strip().upper() - monitor_rule = {'type': monitor_type, 'quorum': quorum, 'monitor_templates': monitor_templates} - monitor_association = {'pool_name': pool, 'monitor_rule': monitor_rule} - api.LocalLB.Pool.set_monitor_association(monitor_associations=[monitor_association]) - - -def get_slow_ramp_time(api, pool): - result = api.LocalLB.Pool.get_slow_ramp_time(pool_names=[pool])[0] - return result - - -def set_slow_ramp_time(api, pool, seconds): - api.LocalLB.Pool.set_slow_ramp_time(pool_names=[pool], values=[seconds]) - - -def get_reselect_tries(api, pool): - result = api.LocalLB.Pool.get_reselect_tries(pool_names=[pool])[0] - return result - - -def set_reselect_tries(api, pool, tries): - api.LocalLB.Pool.set_reselect_tries(pool_names=[pool], values=[tries]) - - -def get_action_on_service_down(api, pool): - result = api.LocalLB.Pool.get_action_on_service_down(pool_names=[pool])[0] - result = result.split("SERVICE_DOWN_ACTION_")[-1].lower() - return result - - -def set_action_on_service_down(api, pool, action): - action = "SERVICE_DOWN_ACTION_%s" % action.strip().upper() - api.LocalLB.Pool.set_action_on_service_down(pool_names=[pool], actions=[action]) - + min_list = list() + prefix = 'min {0} of {{'.format(str(quorum)) + min_list.append(prefix) + for m in monitors: + min_list.append(m) + min_list.append('}') + result = ' '.join(min_list) + + return result + + @property + def host(self): + value = self._values['host'] + if value is None: + return None + msg = "'%s' is not a valid IP address" % value + try: + IPAddress(value) + except AddrFormatError: + raise F5ModuleError(msg) + return value + + @host.setter + def host(self, value): + self._values['host'] = value + + @property + def port(self): + value = self._values['port'] + if value is None: + return None + msg = "The provided port '%s' must be between 0 and 65535" % value + if value < 0 or value > 65535: + raise F5ModuleError(msg) + return value + + @port.setter + def port(self, value): + self._values['port'] = value + + @property + def member_name(self): + if self.host is None or self.port is None: + return None + mname = str(self.host) + ':' + str(self.port) + return mname + + def to_return(self): + result = {} + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + return result + + def api_params(self): + result = {} + for api_attribute in self.api_attributes: + if self.api_map is not None and api_attribute in self.api_map: + result[api_attribute] = getattr( + self, self.api_map[api_attribute] + ) + else: + result[api_attribute] = getattr(self, api_attribute) + result = self._filter_params(result) + return result + + +class ModuleManager(object): + def __init__(self, client): + self.client = client + self.have = None + self.want = Parameters(self.client.module.params) + self.changes = Parameters() + + def exec_module(self): + changed = False + result = dict() + state = self.want.state + + try: + if state == "present": + changed = self.present() + elif state == "absent": + changed = self.absent() + except iControlUnexpectedHTTPError as e: + raise F5ModuleError(str(e)) + + changes = self.changes.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + self._announce_deprecations() + return result + + def _announce_deprecations(self): + warnings = [] + if self.want: + warnings += self.want._values.get('__warnings', []) + if self.have: + warnings += self.have._values.get('__warnings', []) + for warning in warnings: + self.client.module.deprecate( + msg=warning['msg'], + version=warning['version'] + ) -def member_exists(api, pool, address, port): - # hack to determine if member exists - result = False - try: - members = [{'address': address, 'port': port}] - api.LocalLB.Pool.get_member_object_status(pool_names=[pool], - members=[members]) - result = True - except bigsuds.OperationFailed as e: - if "was not found" in str(e): - result = False + def _set_changed_options(self): + changed = {} + for key in Parameters.returnables: + if getattr(self.want, key) is not None: + changed[key] = getattr(self.want, key) + if changed: + self.changes = Parameters(changed) + + def _update_changed_options(self): + changed = {} + for key in Parameters.updatables: + if getattr(self.want, key) is not None: + attr1 = getattr(self.want, key) + attr2 = getattr(self.have, key) + if attr1 != attr2: + changed[key] = attr1 + if changed: + self.changes = Parameters(changed) + return True + return False + + def _member_does_not_exist(self, members): + name = self.want.member_name + # Return False if name is None, so that we don't attempt to create it + if name is None: + return False + for member in members: + if member.name == name: + host, port = name.split(':') + self.have.host = host + self.have.port = int(port) + return False + return True + + def present(self): + if self.exists(): + return self.update() else: - # genuine exception - raise - return result - - -def delete_node_address(api, address): - result = False - try: - api.LocalLB.NodeAddressV2.delete_node_address(nodes=[address]) - result = True - except bigsuds.OperationFailed as e: - if "is referenced by a member of pool" in str(e): - result = False + 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, members, poolres = self.read_current_from_device() + if not self.client.check_mode: + if self._member_does_not_exist(members): + self.create_member_on_device(poolres) + if not self.should_update(): + return False + if self.client.check_mode: + return True + self.update_on_device() + return True + + def remove(self): + if self.client.check_mode: + return True + self.remove_from_device() + if self.exists(): + raise F5ModuleError("Failed to delete the Pool") + return True + + def create(self): + self._set_changed_options() + if self.client.check_mode: + return True + self.create_on_device() + if self.want.member_name: + self.have, members, poolres = self.read_current_from_device() + if self._member_does_not_exist(members): + self.create_member_on_device(poolres) + return True + + def create_on_device(self): + params = self.want.api_params() + self.client.api.tm.ltm.pools.pool.create( + partition=self.want.partition, **params + ) + + def create_member_on_device(self, poolres): + poolres.members_s.members.create( + name=self.want.member_name, + partition=self.want.partition + ) + + def update_on_device(self): + params = self.want.api_params() + result = self.client.api.tm.ltm.pools.pool.load( + name=self.want.name, + partition=self.want.partition + ) + result.modify(**params) + + def exists(self): + return self.client.api.tm.ltm.pools.pool.exists( + name=self.want.name, + partition=self.want.partition + ) + + def remove_from_device(self): + result = self.client.api.tm.ltm.pools.pool.load( + name=self.want.name, + partition=self.want.partition + ) + if self.want.member_name and self.want.port and self.want.pool: + member = result.members_s.members.load( + name=self.want.member_name, + partition=self.want.partition + ) + if member: + member.delete() + self.delete_node_on_device() else: - # genuine exception - raise - return result - - -def remove_pool_member(api, pool, address, port): - members = [{'address': address, 'port': port}] - api.LocalLB.Pool.remove_member_v2(pool_names=[pool], members=[members]) - - -def add_pool_member(api, pool, address, port): - members = [{'address': address, 'port': port}] - api.LocalLB.Pool.add_member_v2(pool_names=[pool], members=[members]) - - -def set_description(api, pool, description): - api.LocalLB.Pool.set_description( - pool_names=[pool], descriptions=[description] - ) - - -def get_description(api, pool): - return api.LocalLB.Pool.get_description(pool_names=[pool])[0] + result.delete() + + def read_current_from_device(self): + tmp_res = self.client.api.tm.ltm.pools.pool.load( + name=self.want.name, + partition=self.want.partition + ) + members = tmp_res.members_s.get_collection() + + result = tmp_res.attrs + return Parameters(result), members, tmp_res + + def delete_node_on_device(self): + resource = self.client.api.tm.ltm.nodes.node.load( + name=self.want.host, + partition=self.want.partition + ) + try: + resource.delete() + except iControlUnexpectedHTTPError as e: + # If we cannot remove it, it is in use, it is up to user to delete + # it later. + if "is referenced by a member of pool" in str(e): + return + else: + raise + + +class ArgumentSpec(object): + def __init__(self): + self.lb_choice_deprecated = [ + 'round_robin', + 'ratio_member', + 'least_connection_member', + 'observed_member', + 'predictive_member', + 'ratio_node_address', + 'least_connection_node_address', + 'fastest_node_address', + 'observed_node_address', + 'predictive_node_address', + 'dynamic_ratio', + 'fastest_app_response', + 'least_sessions', + 'dynamic_ratio_member', + 'ratio_session', + 'weighted_least_connection_member', + 'ratio_least_connection_member', + 'weighted_least_connection_node_address', + 'ratio_least_connection_node_address' + ] + self.lb_choice_removed = [ + 'l3_addr' + ] + self.lb_choice = [ + 'dynamic-ratio-member', + 'dynamic-ratio-node', + 'fastest-app-response', + 'fastest-node', + 'least-connections-member', + 'least-connections-node', + 'least-sessions', + 'observed-member', + 'observed-node', + 'predictive-member', + 'predictive-node', + 'ratio-least-connections-member', + 'ratio-least-connections-node', + 'ratio-member', + 'ratio-node', + 'ratio-session', + 'round-robin', + 'weighted-least-connections-member', + 'weighted-least-connections-node' + ] + lb_choices = self.lb_choice_removed + self.lb_choice + self.lb_choice_deprecated + self.supports_check_mode = True + self.argument_spec = dict( + name=dict( + required=True, + aliases=['pool'] + ), + lb_method=dict( + choices=lb_choices + ), + monitor_type=dict( + choices=[ + 'and_list', 'm_of_n' + ] + ), + quorum=dict( + type='int' + ), + monitors=dict( + type='list' + ), + slow_ramp_time=dict( + type='int' + ), + reselect_tries=dict( + type='int' + ), + service_down_action=dict( + choices=[ + 'none', 'reset', + 'drop', 'reselect' + ] + ), + description=dict(), + host=dict( + aliases=['address'], + removed_in_version='2.4' + ), + port=dict( + type='int', + removed_in_version='2.4' + ) + ) + self.f5_product_name = 'bigip' def main(): - lb_method_choices = ['round_robin', 'ratio_member', - 'least_connection_member', 'observed_member', - 'predictive_member', 'ratio_node_address', - 'least_connection_node_address', - 'fastest_node_address', 'observed_node_address', - 'predictive_node_address', 'dynamic_ratio', - 'fastest_app_response', 'least_sessions', - 'dynamic_ratio_member', 'l3_addr', - 'weighted_least_connection_member', - 'weighted_least_connection_node_address', - 'ratio_session', 'ratio_least_connection_member', - 'ratio_least_connection_node_address'] - - monitor_type_choices = ['and_list', 'm_of_n'] - - service_down_choices = ['none', 'reset', 'drop', 'reselect'] - - argument_spec = f5_argument_spec() - - meta_args = dict( - name=dict(type='str', required=True, aliases=['pool']), - lb_method=dict(type='str', choices=lb_method_choices), - monitor_type=dict(type='str', choices=monitor_type_choices), - quorum=dict(type='int'), - monitors=dict(type='list'), - slow_ramp_time=dict(type='int'), - reselect_tries=dict(type='int'), - service_down_action=dict(type='str', choices=service_down_choices), - host=dict(type='str', aliases=['address']), - port=dict(type='int'), - description=dict(type='str') - ) - argument_spec.update(meta_args) + if not HAS_F5SDK: + raise F5ModuleError("The python f5-sdk module is required") - module = AnsibleModule( - argument_spec=argument_spec, - supports_check_mode=True - ) + spec = ArgumentSpec() - if not bigsuds_found: - module.fail_json(msg="the python bigsuds module is required") - - if module.params['validate_certs']: - import ssl - if not hasattr(ssl, 'SSLContext'): - module.fail_json( - msg='bigsuds does not support verifying certificates with python < 2.7.9. Either update python or set validate_certs=False on the task' - ) - - server = module.params['server'] - server_port = module.params['server_port'] - user = module.params['user'] - password = module.params['password'] - state = module.params['state'] - partition = module.params['partition'] - validate_certs = module.params['validate_certs'] - description = module.params['description'] - - name = module.params['name'] - pool = fq_name(partition, name) - lb_method = module.params['lb_method'] - if lb_method: - lb_method = lb_method.lower() - monitor_type = module.params['monitor_type'] - if monitor_type: - monitor_type = monitor_type.lower() - quorum = module.params['quorum'] - monitors = module.params['monitors'] - if monitors: - monitors = [] - for monitor in module.params['monitors']: - monitors.append(fq_name(partition, monitor)) - slow_ramp_time = module.params['slow_ramp_time'] - reselect_tries = module.params['reselect_tries'] - service_down_action = module.params['service_down_action'] - if service_down_action: - service_down_action = service_down_action.lower() - host = module.params['host'] - address = fq_name(partition, host) - port = module.params['port'] - - # sanity check user supplied values - - if (host and port is None) or (port is not None and not host): - module.fail_json(msg="both host and port must be supplied") - - if port is not None and (0 > port or port > 65535): - module.fail_json(msg="valid ports must be in range 0 - 65535") - - if monitors: - if len(monitors) == 1: - # set default required values for single monitor - quorum = 0 - monitor_type = 'single' - elif len(monitors) > 1: - if not monitor_type: - module.fail_json(msg="monitor_type required for monitors > 1") - if monitor_type == 'm_of_n' and not quorum: - module.fail_json(msg="quorum value required for monitor_type m_of_n") - if monitor_type != 'm_of_n': - quorum = 0 - elif monitor_type: - # no monitors specified but monitor_type exists - module.fail_json(msg="monitor_type require monitors parameter") - elif quorum is not None: - # no monitors specified but quorum exists - module.fail_json(msg="quorum requires monitors parameter") + client = AnsibleF5Client( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + f5_product_name=spec.f5_product_name + ) try: - api = bigip_api(server, user, password, validate_certs, port=server_port) - result = {'changed': False} # default - - if state == 'absent': - if host and port and pool: - # member removal takes precedent - if pool_exists(api, pool) and member_exists(api, pool, address, port): - if not module.check_mode: - remove_pool_member(api, pool, address, port) - deleted = delete_node_address(api, address) - result = {'changed': True, 'deleted': deleted} - else: - result = {'changed': True} - elif pool_exists(api, pool): - # no host/port supplied, must be pool removal - if not module.check_mode: - # hack to handle concurrent runs of module - # pool might be gone before we actually remove it - try: - remove_pool(api, pool) - result = {'changed': True} - except bigsuds.OperationFailed as e: - if "was not found" in str(e): - result = {'changed': False} - else: - # genuine exception - raise - else: - # check-mode return value - result = {'changed': True} - - elif state == 'present': - update = False - if not pool_exists(api, pool): - # pool does not exist -- need to create it - if not module.check_mode: - # a bit of a hack to handle concurrent runs of this module. - # even though we've checked the pool doesn't exist, - # it may exist by the time we run create_pool(). - # this catches the exception and does something smart - # about it! - try: - create_pool(api, pool, lb_method) - result = {'changed': True} - except bigsuds.OperationFailed as e: - if "already exists" in str(e): - update = True - else: - # genuine exception - raise - else: - if monitors: - set_monitors(api, pool, monitor_type, quorum, monitors) - if slow_ramp_time: - set_slow_ramp_time(api, pool, slow_ramp_time) - if reselect_tries: - set_reselect_tries(api, pool, reselect_tries) - if service_down_action: - set_action_on_service_down(api, pool, service_down_action) - if host and port: - add_pool_member(api, pool, address, port) - if description: - set_description(api, pool, description) - else: - # check-mode return value - result = {'changed': True} - else: - # pool exists -- potentially modify attributes - update = True - - if update: - if lb_method and lb_method != get_lb_method(api, pool): - if not module.check_mode: - set_lb_method(api, pool, lb_method) - result = {'changed': True} - if monitors: - t_monitor_type, t_quorum, t_monitor_templates = get_monitors(api, pool) - if (t_monitor_type != monitor_type) or (t_quorum != quorum) or (set(t_monitor_templates) != set(monitors)): - if not module.check_mode: - set_monitors(api, pool, monitor_type, quorum, monitors) - result = {'changed': True} - if slow_ramp_time and slow_ramp_time != get_slow_ramp_time(api, pool): - if not module.check_mode: - set_slow_ramp_time(api, pool, slow_ramp_time) - result = {'changed': True} - if reselect_tries and reselect_tries != get_reselect_tries(api, pool): - if not module.check_mode: - set_reselect_tries(api, pool, reselect_tries) - result = {'changed': True} - if service_down_action and service_down_action != get_action_on_service_down(api, pool): - if not module.check_mode: - set_action_on_service_down(api, pool, service_down_action) - result = {'changed': True} - if (host and port) and not member_exists(api, pool, address, port): - if not module.check_mode: - add_pool_member(api, pool, address, port) - result = {'changed': True} - if (host and port == 0) and not member_exists(api, pool, address, port): - if not module.check_mode: - add_pool_member(api, pool, address, port) - result = {'changed': True} - if description and description != get_description(api, pool): - if not module.check_mode: - set_description(api, pool, description) - result = {'changed': True} - - except Exception as e: - module.fail_json(msg="received exception: %s" % e) - - module.exit_json(**result) - -from ansible.module_utils.basic import * -from ansible.module_utils.f5_utils import * + mm = ModuleManager(client) + results = mm.exec_module() + client.module.exit_json(**results) + except F5ModuleError as e: + client.module.fail_json(msg=str(e)) + if __name__ == '__main__': main() diff --git a/test/units/modules/network/f5/fixtures/load_ltm_pool.json b/test/units/modules/network/f5/fixtures/load_ltm_pool.json new file mode 100644 index 00000000000..cd5be5e1afc --- /dev/null +++ b/test/units/modules/network/f5/fixtures/load_ltm_pool.json @@ -0,0 +1,32 @@ +{ + "kind": "tm:ltm:pool:poolstate", + "name": "test_pool", + "partition": "Common", + "fullPath": "/Common/test_pool", + "generation": 1452, + "selfLink": "https://localhost/mgmt/tm/ltm/pool/~Common~test_pool?ver=11.5.4", + "allowNat": "yes", + "allowSnat": "yes", + "description": "test", + "ignorePersistedWeight": "disabled", + "ipTosToClient": "pass-through", + "ipTosToServer": "pass-through", + "linkQosToClient": "pass-through", + "linkQosToServer": "pass-through", + "loadBalancingMode": "round-robin", + "minActiveMembers": 0, + "minUpMembers": 0, + "minUpMembersAction": "failover", + "minUpMembersChecking": "disabled", + "monitor": "min 1 of { /Common/http /Common/inband }", + "queueDepthLimit": 0, + "queueOnConnectionLimit": "disabled", + "queueTimeLimit": 0, + "reselectTries": 0, + "serviceDownAction": "reselect", + "slowRampTime": 10, + "membersReference": { + "link": "https://localhost/mgmt/tm/ltm/pool/~Common~test_pool/members?ver=11.5.4", + "isSubcollection": true + } +} \ No newline at end of file diff --git a/test/units/modules/network/f5/fixtures/pool_members_subcollection.json b/test/units/modules/network/f5/fixtures/pool_members_subcollection.json new file mode 100644 index 00000000000..1041f8f62c3 --- /dev/null +++ b/test/units/modules/network/f5/fixtures/pool_members_subcollection.json @@ -0,0 +1,21 @@ +[ + { + "kind": "tm:ltm:pool:members:membersstate", + "name": "1.1.1.1:80", + "partition": "Common", + "fullPath": "/Common/1.1.1.1:80", + "generation": 1, + "selfLink": "https://localhost/mgmt/tm/ltm/pool/~Common~test_pool/members/~Common~1.1.1.1:80?ver=11.5.4", + "address": "1.1.1.1", + "connectionLimit": 0, + "dynamicRatio": 1, + "inheritProfile": "enabled", + "logging": "disabled", + "monitor": "default", + "priorityGroup": 0, + "rateLimit": "disabled", + "ratio": 1, + "session": "user-disabled", + "state": "up" + } + ] diff --git a/test/units/modules/network/f5/test_bigip_pool.py b/test/units/modules/network/f5/test_bigip_pool.py new file mode 100644 index 00000000000..b704d29c07d --- /dev/null +++ b/test/units/modules/network/f5/test_bigip_pool.py @@ -0,0 +1,643 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2017 F5 Networks Inc. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public Liccense for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import sys + +if sys.version_info < (2, 7): + from nose.plugins.skip import SkipTest + raise SkipTest("F5 Ansible modules require Python >= 2.7") + +import os +import json +import pytest + +from ansible.compat.tests import unittest +from ansible.compat.tests.mock import patch, Mock +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes +from ansible.module_utils.f5_utils import AnsibleF5Client +from ansible.module_utils.f5_utils import F5ModuleError + +try: + from library.bigip_pool import Parameters + from library.bigip_pool import ModuleManager + from library.bigip_pool import ArgumentSpec +except ImportError: + from ansible.modules.network.f5.bigip_pool import Parameters + from ansible.modules.network.f5.bigip_pool import ModuleManager + from ansible.modules.network.f5.bigip_pool import ArgumentSpec + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +def set_module_args(args): + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +def load_fixture(name): + path = os.path.join(fixture_path, name) + + if path in fixture_data: + return fixture_data[path] + + with open(path) as f: + data = f.read() + + try: + data = json.loads(data) + except Exception: + pass + + fixture_data[path] = data + return data + + +class BigIpObj(object): + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +class TestParameters(unittest.TestCase): + def test_module_parameters(self): + m = ['/Common/Fake', '/Common/Fake2'] + args = dict( + monitor_type='m_of_n', + monitors=m, + quorum=1, + slow_ramp_time=200, + reselect_tries=5, + service_down_action='drop', + host='192.168.1.1', + port=8080 + ) + + p = Parameters(args) + assert p.monitor_type == 'm_of_n' + assert p.quorum == 1 + assert p.monitors == m + assert p.monitor == 'min 1 of { /Common/Fake /Common/Fake2 }' + assert p.host == '192.168.1.1' + assert p.port == 8080 + assert p.member_name == '192.168.1.1:8080' + assert p.slow_ramp_time == 200 + assert p.reselect_tries == 5 + assert p.service_down_action == 'drop' + + def test_api_parameters(self): + m = ['/Common/Fake', '/Common/Fake2'] + args = dict( + monitor_type='and_list', + monitors=m, + slowRampTime=200, + reselectTries=5, + serviceDownAction='drop' + ) + + p = Parameters(args) + assert p.monitor == '/Common/Fake and /Common/Fake2' + assert p.slow_ramp_time == 200 + assert p.reselect_tries == 5 + assert p.service_down_action == 'drop' + + def test_unknown_module_lb_method(self): + args = dict( + lb_method='obscure_hyphenated_fake_method', + ) + with pytest.raises(F5ModuleError): + p = Parameters(args) + assert p.lb_method == 'foo' + + def test_unknown_api_lb_method(self): + args = dict( + loadBalancingMode='obscure_hypenated_fake_method' + ) + with pytest.raises(F5ModuleError): + p = Parameters(args) + assert p.lb_method == 'foo' + + +@patch('ansible.module_utils.f5_utils.AnsibleF5Client._get_mgmt_root', + return_value=True) +class TestManager(unittest.TestCase): + + def setUp(self): + self.spec = ArgumentSpec() + self.loaded_members = [] + members = load_fixture('pool_members_subcollection.json') + for item in members: + self.loaded_members.append(BigIpObj(**item)) + + def test_create_pool(self, *args): + set_module_args(dict( + pool='fake_pool', + description='fakepool', + service_down_action='drop', + lb_method='round_robin', + partition='Common', + slow_ramp_time=10, + reselect_tries=1, + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + mm = ModuleManager(client) + mm.create_on_device = lambda: True + mm.exists = lambda: False + + results = mm.exec_module() + + assert results['changed'] is True + assert results['name'] == 'fake_pool' + assert results['description'] == 'fakepool' + assert results['service_down_action'] == 'drop' + assert results['lb_method'] == 'round-robin' + assert results['slow_ramp_time'] == 10 + assert results['reselect_tries'] == 1 + + def test_create_pool_with_pool_member(self, *args): + set_module_args(dict( + pool='fake_pool', + partition='Common', + host='192.168.1.1', + port=8080, + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + current = ( + Parameters( + load_fixture('load_ltm_pool.json') + ), + self.loaded_members, + {}, + ) + + mm = ModuleManager(client) + mm.create_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=False) + mm.read_current_from_device = Mock(return_value=current) + mm.create_member_on_device = Mock(return_value=True) + + results = mm.exec_module() + + assert results['changed'] is True + assert results['host'] == '192.168.1.1' + assert results['port'] == 8080 + assert results['member_name'] == '192.168.1.1:8080' + + def test_create_pool_invalid_host(self, *args): + set_module_args(dict( + pool='fake_pool', + partition='Common', + host='this.will.fail', + port=8080, + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + mm = ModuleManager(client) + mm.create_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=False) + + msg = "'this.will.fail' is not a valid IP address" + + with pytest.raises(F5ModuleError) as err: + mm.exec_module() + assert str(err.value) == msg + + def test_create_pool_invalid_port(self, *args): + set_module_args(dict( + pool='fake_pool', + partition='Common', + host='192.168.1.1', + port=98741, + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + mm = ModuleManager(client) + mm.create_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=False) + + msg = "The provided port '98741' must be between 0 and 65535" + + with pytest.raises(F5ModuleError) as err: + mm.exec_module() + assert str(err.value) == msg + + def test_create_pool_monitor_type_missing(self, *args): + set_module_args(dict( + pool='fake_pool', + lb_method='round_robin', + partition='Common', + monitors=['/Common/tcp', '/Common/http'], + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + mm = ModuleManager(client) + mm.create_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=False) + + msg = "The 'monitor_type' parameter cannot be empty when " \ + "'monitors' parameter is specified." + with pytest.raises(F5ModuleError) as err: + mm.exec_module() + + assert str(err.value) == msg + + def test_create_pool_monitors_missing(self, *args): + set_module_args(dict( + pool='fake_pool', + lb_method='round_robin', + partition='Common', + monitor_type='and_list', + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + mm = ModuleManager(client) + mm.create_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=False) + + msg = "The 'monitor' parameter cannot be empty when " \ + "'monitor_type' parameter is specified" + with pytest.raises(F5ModuleError) as err: + mm.exec_module() + + assert str(err.value) == msg + + def test_create_pool_quorum_missing(self, *args): + set_module_args(dict( + pool='fake_pool', + lb_method='round_robin', + partition='Common', + monitor_type='m_of_n', + monitors=['/Common/tcp', '/Common/http'], + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + mm = ModuleManager(client) + mm.create_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=False) + + msg = "Quorum value must be specified with monitor_type 'm_of_n'." + with pytest.raises(F5ModuleError) as err: + mm.exec_module() + + assert str(err.value) == msg + + def test_create_pool_monitor_and_list(self, *args): + set_module_args(dict( + pool='fake_pool', + partition='Common', + monitor_type='and_list', + monitors=['/Common/tcp', '/Common/http'], + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + mm = ModuleManager(client) + mm.create_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=False) + + results = mm.exec_module() + + assert results['changed'] is True + assert results['name'] == 'fake_pool' + assert results['monitors'] == ['/Common/tcp', '/Common/http'] + assert results['monitor_type'] == 'and_list' + assert results['monitor'] == '/Common/tcp and /Common/http' + + def test_create_pool_monitor_m_of_n(self, *args): + set_module_args(dict( + pool='fake_pool', + partition='Common', + monitor_type='m_of_n', + quorum=1, + monitors=['/Common/tcp', '/Common/http'], + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + mm = ModuleManager(client) + mm.create_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=False) + + results = mm.exec_module() + + assert results['changed'] is True + assert results['name'] == 'fake_pool' + assert results['monitors'] == ['/Common/tcp', '/Common/http'] + assert results['monitor_type'] == 'm_of_n' + assert results['monitor'] == 'min 1 of { /Common/tcp /Common/http }' + + def test_update_monitors(self, *args): + set_module_args(dict( + name='test_pool', + partition='Common', + monitor_type='and_list', + monitors=['/Common/http', '/Common/tcp'], + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + mm = ModuleManager(client) + + current = ( + Parameters( + load_fixture('load_ltm_pool.json') + ), + [], + {}, + ) + + mm.update_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=True) + mm.read_current_from_device = Mock(return_value=current) + + results = mm.exec_module() + + assert results['changed'] is True + assert results['monitors'] == ['/Common/http', '/Common/tcp'] + assert results['monitor_type'] == 'and_list' + assert results['monitor'] == '/Common/http and /Common/tcp' + + def test_update_pool_new_member(self, *args): + set_module_args(dict( + name='test_pool', + partition='Common', + host='192.168.1.1', + port=8080, + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + mm = ModuleManager(client) + + current = ( + Parameters( + load_fixture('load_ltm_pool.json') + ), + self.loaded_members, + {}, + ) + + mm.update_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=True) + mm.read_current_from_device = Mock(return_value=current) + mm.create_member_on_device = Mock(return_value=True) + + results = mm.exec_module() + + assert results['changed'] is True + assert results['host'] == '192.168.1.1' + assert results['port'] == 8080 + + def test_update_pool_member_exists(self, *args): + set_module_args(dict( + name='test_pool', + partition='Common', + host='1.1.1.1', + port=80, + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + mm = ModuleManager(client) + + current = ( + Parameters( + load_fixture('load_ltm_pool.json') + ), + self.loaded_members, + {}, + ) + + mm.update_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=True) + mm.read_current_from_device = Mock(return_value=current) + mm.create_member_on_device = Mock(return_value=True) + + results = mm.exec_module() + + assert results['changed'] is False + + def test_create_pool_monitor_and_list_no_partition(self, *args): + set_module_args(dict( + pool='fake_pool', + monitor_type='and_list', + monitors=['tcp', 'http'], + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + mm = ModuleManager(client) + mm.create_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=False) + + results = mm.exec_module() + + assert results['changed'] is True + assert results['name'] == 'fake_pool' + assert results['monitors'] == ['/Common/tcp', '/Common/http'] + assert results['monitor_type'] == 'and_list' + assert results['monitor'] == '/Common/tcp and /Common/http' + + def test_create_pool_monitor_m_of_n_no_partition(self, *args): + set_module_args(dict( + pool='fake_pool', + monitor_type='m_of_n', + quorum=1, + monitors=['tcp', 'http'], + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + mm = ModuleManager(client) + mm.create_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=False) + + results = mm.exec_module() + + assert results['changed'] is True + assert results['name'] == 'fake_pool' + assert results['monitors'] == ['/Common/tcp', '/Common/http'] + assert results['monitor_type'] == 'm_of_n' + assert results['monitor'] == 'min 1 of { /Common/tcp /Common/http }' + + def test_create_pool_monitor_and_list_custom_partition(self, *args): + set_module_args(dict( + pool='fake_pool', + partition='Testing', + monitor_type='and_list', + monitors=['tcp', 'http'], + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + mm = ModuleManager(client) + mm.create_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=False) + + results = mm.exec_module() + + assert results['changed'] is True + assert results['name'] == 'fake_pool' + assert results['monitors'] == ['/Testing/tcp', '/Testing/http'] + assert results['monitor_type'] == 'and_list' + assert results['monitor'] == '/Testing/tcp and /Testing/http' + + def test_create_pool_monitor_m_of_n_custom_partition(self, *args): + set_module_args(dict( + pool='fake_pool', + partition='Testing', + monitor_type='m_of_n', + quorum=1, + monitors=['tcp', 'http'], + server='localhost', + password='password', + user='admin' + )) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + mm = ModuleManager(client) + mm.create_on_device = Mock(return_value=True) + mm.exists = Mock(return_value=False) + + results = mm.exec_module() + + assert results['changed'] is True + assert results['name'] == 'fake_pool' + assert results['monitors'] == ['/Testing/tcp', '/Testing/http'] + assert results['monitor_type'] == 'm_of_n' + assert results['monitor'] == 'min 1 of { /Testing/tcp /Testing/http }'