From 8085c38e054b543fc233efae3f3391c13ecffbe0 Mon Sep 17 00:00:00 2001 From: Tim Rupp Date: Fri, 20 Oct 2017 18:05:45 -0700 Subject: [PATCH] Refactors the bigip_gtm_facts module (#31917) Includes pep fixes and inlining code with current conventions --- .../modules/network/f5/bigip_gtm_facts.py | 1182 ++++++++++++----- test/sanity/pep8/legacy-files.txt | 1 - .../fixtures/load_gtm_pool_a_collection.json | 44 + .../load_gtm_pool_a_example_stats.json | 48 + .../network/f5/test_bigip_gtm_facts.py | 157 +++ 5 files changed, 1076 insertions(+), 356 deletions(-) create mode 100644 test/units/modules/network/f5/fixtures/load_gtm_pool_a_collection.json create mode 100644 test/units/modules/network/f5/fixtures/load_gtm_pool_a_example_stats.json create mode 100644 test/units/modules/network/f5/test_bigip_gtm_facts.py diff --git a/lib/ansible/modules/network/f5/bigip_gtm_facts.py b/lib/ansible/modules/network/f5/bigip_gtm_facts.py index 5103aa73817..8551203c903 100644 --- a/lib/ansible/modules/network/f5/bigip_gtm_facts.py +++ b/lib/ansible/modules/network/f5/bigip_gtm_facts.py @@ -4,22 +4,26 @@ # 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 +__metaclass__ = type + + ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} -DOCUMENTATION = ''' +DOCUMENTATION = r''' --- module: bigip_gtm_facts -short_description: Collect facts from F5 BIG-IP GTM devices. +short_description: Collect facts from F5 BIG-IP GTM devices description: - Collect facts from F5 BIG-IP GTM devices. version_added: "2.3" options: include: description: - - Fact category to collect - required: true + - Fact category to collect. + required: True choices: - pool - wide_ip @@ -29,11 +33,9 @@ options: - Perform regex filter of response. Filtering is done on the name of the resource. Valid filters are anything that can be provided to Python's C(re) module. - required: false - default: None notes: - - Requires the f5-sdk Python package on the host. This is as easy as - pip install f5-sdk + - Requires the f5-sdk Python package on the host. This is as easy as + pip install f5-sdk extends_documentation_fragment: f5 requirements: - f5-sdk @@ -41,156 +43,162 @@ author: - Tim Rupp (@caphrim007) ''' -EXAMPLES = ''' +EXAMPLES = r''' - name: Get pool facts bigip_gtm_facts: - server: "lb.mydomain.com" - user: "admin" - password: "secret" - include: "pool" - filter: "my_pool" + server: lb.mydomain.com + user: admin + password: secret + include: pool + filter: my_pool delegate_to: localhost ''' -RETURN = ''' +RETURN = r''' wide_ip: - description: - Contains the lb method for the wide ip and the pools - that are within the wide ip. - returned: changed - type: dict - sample: - wide_ip: - - enabled: "True" - failure_rcode: "noerror" - failure_rcode_response: "disabled" - failure_rcode_ttl: "0" - full_path: "/Common/foo.ok.com" - last_resort_pool: "" - minimal_response: "enabled" - name: "foo.ok.com" - partition: "Common" - persist_cidr_ipv4: "32" - persist_cidr_ipv6: "128" - persistence: "disabled" - pool_lb_mode: "round-robin" - pools: - - name: "d3qw" - order: "0" - partition: "Common" - ratio: "1" - ttl_persistence: "3600" - type: "naptr" + description: + Contains the lb method for the wide ip and the pools that are within the wide ip. + returned: changed + type: list + sample: + wide_ip: + - enabled: True + failure_rcode: noerror + failure_rcode_response: disabled + failure_rcode_ttl: 0 + full_path: /Common/foo.ok.com + last_resort_pool: "" + minimal_response: enabled + name: foo.ok.com + partition: Common + persist_cidr_ipv4: 32 + persist_cidr_ipv6: 128 + persistence: disabled + pool_lb_mode: round-robin + pools: + - name: d3qw + order: 0 + partition: Common + ratio: 1 + ttl_persistence: 3600 + type: naptr pool: - description: Contains the pool object status and enabled status. - returned: changed - type: dict - sample: - pool: - - alternate_mode: "round-robin" - dynamic_ratio: "disabled" - enabled: "True" - fallback_mode: "return-to-dns" - full_path: "/Common/d3qw" - load_balancing_mode: "round-robin" - manual_resume: "disabled" - max_answers_returned: "1" - members: - - disabled: "True" - flags: "a" - full_path: "ok3.com" - member_order: "0" - name: "ok3.com" - order: "10" - preference: "10" - ratio: "1" - service: "80" - name: "d3qw" - partition: "Common" - qos_hit_ratio: "5" - qos_hops: "0" - qos_kilobytes_second: "3" - qos_lcs: "30" - qos_packet_rate: "1" - qos_rtt: "50" - qos_topology: "0" - qos_vs_capacity: "0" - qos_vs_score: "0" - ttl: "30" - type: "naptr" - verify_member_availability: "disabled" + description: Contains the pool object status and enabled status. + returned: changed + type: list + sample: + pool: + - alternate_mode: round-robin + dynamic_ratio: disabled + enabled: True + fallback_mode: return-to-dns + full_path: /Common/d3qw + load_balancing_mode: round-robin + manual_resume: disabled + max_answers_returned: 1 + members: + - disabled: True + flags: a + full_path: ok3.com + member_order: 0 + name: ok3.com + order: 10 + preference: 10 + ratio: 1 + service: 80 + name: d3qw + partition: Common + qos_hit_ratio: 5 + qos_hops: 0 + qos_kilobytes_second: 3 + qos_lcs: 30 + qos_packet_rate: 1 + qos_rtt: 50 + qos_topology: 0 + qos_vs_capacity: 0 + qos_vs_score: 0 + availability_state: offline + enabled_state: disabled + ttl: 30 + type: naptr + verify_member_availability: disabled virtual_server: - description: - Contains the virtual server enabled and availability - status, and address - returned: changed - type: dict - sample: - virtual_server: - - addresses: - - device_name: "/Common/qweqwe" - name: "10.10.10.10" - translation: "none" - datacenter: "/Common/xfxgh" - enabled: "True" - expose_route_domains: "no" - full_path: "/Common/qweqwe" - iq_allow_path: "yes" - iq_allow_service_check: "yes" - iq_allow_snmp: "yes" - limit_cpu_usage: "0" - limit_cpu_usage_status: "disabled" - limit_max_bps: "0" - limit_max_bps_status: "disabled" - limit_max_connections: "0" - limit_max_connections_status: "disabled" - limit_max_pps: "0" - limit_max_pps_status: "disabled" - limit_mem_avail: "0" - limit_mem_avail_status: "disabled" - link_discovery: "disabled" - monitor: "/Common/bigip " - name: "qweqwe" - partition: "Common" - product: "single-bigip" - virtual_server_discovery: "disabled" - virtual_servers: - - destination: "10.10.10.10:0" - enabled: "True" - full_path: "jsdfhsd" - limit_max_bps: "0" - limit_max_bps_status: "disabled" - limit_max_connections: "0" - limit_max_connections_status: "disabled" - limit_max_pps: "0" - limit_max_pps_status: "disabled" - name: "jsdfhsd" - translation_address: "none" - translation_port: "0" + description: + Contains the virtual server enabled and availability status, and address. + returned: changed + type: list + sample: + virtual_server: + - addresses: + - device_name: /Common/qweqwe + name: 10.10.10.10 + translation: none + datacenter: /Common/xfxgh + enabled: True + expose_route_domains: no + full_path: /Common/qweqwe + iq_allow_path: yes + iq_allow_service_check: yes + iq_allow_snmp: yes + limit_cpu_usage: 0 + limit_cpu_usage_status: disabled + limit_max_bps: 0 + limit_max_bps_status: disabled + limit_max_connections: 0 + limit_max_connections_status: disabled + limit_max_pps: 0 + limit_max_pps_status: disabled + limit_mem_avail: 0 + limit_mem_avail_status: disabled + link_discovery: disabled + monitor: /Common/bigip + name: qweqwe + partition: Common + product: single-bigip + virtual_server_discovery: disabled + virtual_servers: + - destination: 10.10.10.10:0 + enabled: True + full_path: jsdfhsd + limit_max_bps: 0 + limit_max_bps_status: disabled + limit_max_connections: 0 + limit_max_connections_status: disabled + limit_max_pps: 0 + limit_max_pps_status: disabled + name: jsdfhsd + translation_address: none + translation_port: 0 ''' +import re + try: - from distutils.version import LooseVersion - from f5.bigip.contexts import TransactionContextManager - from f5.bigip import ManagementRoot - from icontrol.session import iControlUnexpectedHTTPError + import json +except ImportError: + import simplejson as json + +from ansible.module_utils.f5_utils import AnsibleF5Client +from ansible.module_utils.f5_utils import AnsibleF5Parameters +from ansible.module_utils.f5_utils import HAS_F5SDK +from ansible.module_utils.f5_utils import F5ModuleError +from ansible.module_utils.parsing.convert_bool import BOOLEANS_TRUE +from ansible.module_utils.parsing.convert_bool import BOOLEANS_FALSE +from ansible.module_utils.six import iteritems +from collections import defaultdict +from distutils.version import LooseVersion - HAS_F5SDK = True +try: + from f5.utils.responses.handlers import Stats + from ansible.module_utils.f5_utils import iControlUnexpectedHTTPError except ImportError: HAS_F5SDK = False -import re - -class BigIpGtmFactsCommon(object): - def __init__(self): - self.api = None - self.attributes_to_remove = [ - 'kind', 'generation', 'selfLink', '_meta_data', - 'membersReference', 'datacenterReference', - 'virtualServersReference', 'nameReference' - ] - self.gtm_types = dict( +class BaseManager(object): + def __init__(self, client): + self.client = client + self.types = dict( a_s='a', aaaas='aaaa', cnames='cname', @@ -198,34 +206,27 @@ class BigIpGtmFactsCommon(object): naptrs='naptr', srvs='srv' ) - self.request_params = dict( - params='expandSubcollections=true' - ) - - def is_version_less_than_12(self): - version = self.api.tmos_version - if LooseVersion(version) < LooseVersion('12.0.0'): - return True - else: - return False - def format_string_facts(self, parameters): - result = dict() - for attribute in self.attributes_to_remove: - parameters.pop(attribute, None) - for key, val in parameters.items(): - result[key] = str(val) + def exec_module(self): + result = self.read_current_from_device() return result def filter_matches_name(self, name): - if not self.params['filter']: + if self.want.filter is None: return True - matches = re.match(self.params['filter'], str(name)) + matches = re.match(self.want.filter, str(name)) if matches: return True else: return False + def version_is_less_than_12(self): + version = self.client.api.tmos_version + if LooseVersion(version) < LooseVersion('12.0.0'): + return True + else: + return False + def get_facts_from_collection(self, collection, collection_type=None): results = [] for item in collection: @@ -235,247 +236,718 @@ class BigIpGtmFactsCommon(object): results.append(facts) return results - def connect_to_bigip(self, **kwargs): - return ManagementRoot(kwargs['server'], - kwargs['user'], - kwargs['password'], - port=kwargs['server_port']) + def read_stats_from_device(self, resource): + stats = Stats(resource.stats.load()) + return stats.stat + +class UntypedManager(BaseManager): + def exec_module(self): + results = [] + facts = self.read_facts() + for item in facts: + filtered = [(k, v) for k, v in iteritems(item) if self.filter_matches_name(k)] + if filtered: + results.append(dict(filtered)) + return results -class BigIpGtmFactsPools(BigIpGtmFactsCommon): - def __init__(self, *args, **kwargs): - super(BigIpGtmFactsPools, self).__init__() - self.params = kwargs - def get_facts(self): - self.api = self.connect_to_bigip(**self.params) - return self.get_facts_from_device() +class TypedManager(BaseManager): + def exec_module(self): + results = [] + for collection, type in iteritems(self.types): + facts = self.read_facts(collection) + if not facts: + continue + for x in facts: + x.update({'type': type}) + for item in facts: + attrs = item.to_return() + filtered = [(k, v) for k, v in iteritems(attrs) if self.filter_matches_name(k)] + if filtered: + results.append(dict(filtered)) + return results - def get_facts_from_device(self): + +class Parameters(AnsibleF5Parameters): + def __init__(self, params=None): + super(Parameters, self).__init__(params) + self._values['__warnings'] = [] + + @property + def include(self): + requested = self._values['include'] + valid = ['pool', 'wide_ip', 'virtual_server', 'server', 'all'] + + if any(x for x in requested if x not in valid): + raise F5ModuleError( + "The valid 'include' choices are {0}".format(', '.join(valid)) + ) + if any(x for x in requested if x == 'virtual_server'): + self._values['__warnings'].append( + dict( + msg="The 'virtual_server' param is deprecated. Use 'server' instead", + version='2.5' + ) + ) + + if 'all' in requested: + return ['all'] + else: + return requested + + +class BaseParameters(AnsibleF5Parameters): + def __init__(self, params=None): + self._values = defaultdict(lambda: None) + if params: + self.update(params=params) + self._values['__warnings'] = [] + + def update(self, params=None): + if params: + for k, v in iteritems(params): + if self.api_map is not None and k in self.api_map: + map_key = self.api_map[k] + else: + map_key = k + + # Handle weird API parameters like `dns.proxy.__iter__` by + # using a map provided by the module developer + class_attr = getattr(type(self), map_key, None) + if isinstance(class_attr, property): + # There is a mapped value for the api_map key + if class_attr.fset is None: + # If the mapped value does not have an associated setter + self._values[map_key] = v + else: + # The mapped value has a setter + setattr(self, map_key, v) + else: + # If the mapped value is not a @property + self._values[map_key] = v + + @property + def enabled(self): + if self._values['enabled'] is None: + return None + elif self._values['enabled'] in BOOLEANS_TRUE: + return True + else: + return False + + @property + def disabled(self): + if self._values['disabled'] is None: + return None + elif self._values['disabled'] in BOOLEANS_TRUE: + return True + else: + return False + + def _remove_internal_keywords(self, resource): + del resource['kind'] + del resource['generation'] + del resource['selfLink'] + + def to_return(self): + result = {} + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + return result + + +class PoolParameters(BaseParameters): + api_map = { + 'alternateMode': 'alternate_mode', + 'dynamicRatio': 'dynamic_ratio', + 'fallbackMode': 'fallback_mode', + 'fullPath': 'full_path', + 'loadBalancingMode': 'load_balancing_mode', + 'manualResume': 'manual_resume', + 'maxAnswersReturned': 'max_answers_returned', + 'qosHitRatio': 'qos_hit_ratio', + 'qosHops': 'qos_hops', + 'qosKilobytesSecond': 'qos_kilobytes_second', + 'qosLcs': 'qos_lcs', + 'qosPacketRate': 'qos_packet_rate', + 'qosRtt': 'qos_rtt', + 'qosTopology': 'qos_topology', + 'qosVsCapacity': 'qos_vs_capacity', + 'qosVsScore': 'qos_vs_score', + 'verifyMemberAvailability': 'verify_member_availability', + 'membersReference': 'members' + } + + returnables = [ + 'alternate_mode', 'dynamic_ratio', 'enabled', 'disabled', 'fallback_mode', + 'load_balancing_mode', 'manual_resume', 'max_answers_returned', 'members', + 'name', 'partition', 'qos_hit_ratio', 'qos_hops', 'qos_kilobytes_second', + 'qos_lcs', 'qos_packet_rate', 'qos_rtt', 'qos_topology', 'qos_vs_capacity', + 'qos_vs_score', 'ttl', 'type', 'full_path', 'availability_state', + 'enabled_state', 'availability_status' + ] + + @property + def max_answers_returned(self): + if self._values['max_answers_returned'] is None: + return None + return int(self._values['max_answers_returned']) + + @property + def members(self): + result = [] + if self._values['members'] is None or 'items' not in self._values['members']: + return result + for item in self._values['members']['items']: + self._remove_internal_keywords(item) + if 'disabled' in item: + if item['disabled'] in BOOLEANS_TRUE: + item['disabled'] = True + else: + item['disabled'] = False + if 'enabled' in item: + if item['enabled'] in BOOLEANS_TRUE: + item['enabled'] = True + else: + item['enabled'] = False + if 'fullPath' in item: + item['full_path'] = item.pop('fullPath') + if 'memberOrder' in item: + item['member_order'] = int(item.pop('memberOrder')) + # Cast some attributes to integer + for x in ['order', 'preference', 'ratio', 'service']: + if x in item: + item[x] = int(item[x]) + result.append(item) + return result + + @property + def qos_hit_ratio(self): + if self._values['qos_hit_ratio'] is None: + return None + return int(self._values['qos_hit_ratio']) + + @property + def qos_hops(self): + if self._values['qos_hops'] is None: + return None + return int(self._values['qos_hops']) + + @property + def qos_kilobytes_second(self): + if self._values['qos_kilobytes_second'] is None: + return None + return int(self._values['qos_kilobytes_second']) + + @property + def qos_lcs(self): + if self._values['qos_lcs'] is None: + return None + return int(self._values['qos_lcs']) + + @property + def qos_packet_rate(self): + if self._values['qos_packet_rate'] is None: + return None + return int(self._values['qos_packet_rate']) + + @property + def qos_rtt(self): + if self._values['qos_rtt'] is None: + return None + return int(self._values['qos_rtt']) + + @property + def qos_topology(self): + if self._values['qos_topology'] is None: + return None + return int(self._values['qos_topology']) + + @property + def qos_vs_capacity(self): + if self._values['qos_vs_capacity'] is None: + return None + return int(self._values['qos_vs_capacity']) + + @property + def qos_vs_score(self): + if self._values['qos_vs_score'] is None: + return None + return int(self._values['qos_vs_score']) + + @property + def availability_state(self): + if self._values['stats'] is None: + return None + try: + result = self._values['stats']['status_availabilityState'] + return result['description'] + except AttributeError: + return None + + @property + def enabled_state(self): + if self._values['stats'] is None: + return None try: - if self.is_version_less_than_12(): - return self.get_facts_without_types() + result = self._values['stats']['status_enabledState'] + return result['description'] + except AttributeError: + return None + + @property + def availability_status(self): + # This fact is a combination of the availability_state and enabled_state + # + # The purpose of the fact is to give a higher-level view of the availability + # of the pool, that can be used in playbooks. If you need further detail, + # consider using the following facts together. + # + # - availability_state + # - enabled_state + # + if self.enabled_state == 'enabled': + if self.availability_state == 'offline': + return 'red' + elif self.availability_state == 'available': + return 'green' + elif self.availability_state == 'unknown': + return 'blue' else: - return self.get_facts_with_types() - except iControlUnexpectedHTTPError as e: - raise F5ModuleError(str(e)) + return 'none' + else: + # disabled + return 'black' + + +class WideIpParameters(BaseParameters): + api_map = { + 'fullPath': 'full_path', + 'failureRcode': 'failure_return_code', + 'failureRcodeResponse': 'failure_return_code_response', + 'failureRcodeTtl': 'failure_return_code_ttl', + 'lastResortPool': 'last_resort_pool', + 'minimalResponse': 'minimal_response', + 'persistCidrIpv4': 'persist_cidr_ipv4', + 'persistCidrIpv6': 'persist_cidr_ipv6', + 'poolLbMode': 'pool_lb_mode', + 'ttlPersistence': 'ttl_persistence' + } + + returnables = [ + 'full_path', 'description', 'enabled', 'disabled', 'failure_return_code', + 'failure_return_code_response', 'failure_return_code_ttl', 'last_resort_pool', + 'minimal_response', 'persist_cidr_ipv4', 'persist_cidr_ipv6', 'pool_lb_mode', + 'ttl_persistence', 'pools' + ] + + @property + def pools(self): + result = [] + if self._values['pools'] is None: + return [] + for pool in self._values['pools']: + del pool['nameReference'] + for x in ['order', 'ratio']: + if x in pool: + pool[x] = int(pool[x]) + result.append(pool) + return result - def get_facts_with_types(self): + @property + def failure_return_code_ttl(self): + if self._values['failure_return_code_ttl'] is None: + return None + return int(self._values['failure_return_code_ttl']) + + @property + def persist_cidr_ipv4(self): + if self._values['persist_cidr_ipv4'] is None: + return None + return int(self._values['persist_cidr_ipv4']) + + @property + def persist_cidr_ipv6(self): + if self._values['persist_cidr_ipv6'] is None: + return None + return int(self._values['persist_cidr_ipv6']) + + @property + def ttl_persistence(self): + if self._values['ttl_persistence'] is None: + return None + return int(self._values['ttl_persistence']) + + +class ServerParameters(BaseParameters): + api_map = { + 'fullPath': 'full_path', + 'exposeRouteDomains': 'expose_route_domains', + 'iqAllowPath': 'iq_allow_path', + 'iqAllowServiceCheck': 'iq_allow_service_check', + 'iqAllowSnmp': 'iq_allow_snmp', + 'limitCpuUsage': 'limit_cpu_usage', + 'limitCpuUsageStatus': 'limit_cpu_usage_status', + 'limitMaxBps': 'limit_max_bps', + 'limitMaxBpsStatus': 'limit_max_bps_status', + 'limitMaxConnections': 'limit_max_connections', + 'limitMaxConnectionsStatus': 'limit_max_connections_status', + 'limitMaxPps': 'limit_max_pps', + 'limitMaxPpsStatus': 'limit_max_pps_status', + 'limitMemAvail': 'limit_mem_available', + 'limitMemAvailStatus': 'limit_mem_available_status', + 'linkDiscovery': 'link_discovery', + 'proberFallback': 'prober_fallback', + 'proberPreference': 'prober_preference', + 'virtualServerDiscovery': 'virtual_server_discovery', + 'devicesReference': 'devices', + 'virtualServersReference': 'virtual_servers' + } + + returnables = [ + 'datacenter', 'enabled', 'disabled', 'expose_route_domains', 'iq_allow_path', + 'full_path', 'iq_allow_service_check', 'iq_allow_snmp', 'limit_cpu_usage', + 'limit_cpu_usage_status', 'limit_max_bps', 'limit_max_bps_status', + 'limit_max_connections', 'limit_max_connections_status', 'limit_max_pps', + 'limit_max_pps_status', 'limit_mem_available', 'limit_mem_available_status', + 'link_discovery', 'monitor', 'product', 'prober_fallback', 'prober_preference', + 'virtual_server_discovery', 'addresses', 'devices', 'virtual_servers' + ] + + @property + def devices(self): result = [] - for key, type in self.gtm_types.items(): - facts = self.get_all_facts_by_type(key, type) - if facts: - result.append(facts) + if self._values['devices'] is None or 'items' not in self._values['devices']: + return result + for item in self._values['devices']['items']: + self._remove_internal_keywords(item) + if 'fullPath' in item: + item['full_path'] = item.pop('fullPath') + result.append(item) return result - def get_facts_without_types(self): - pools = self.api.tm.gtm.pools.get_collection(**self.request_params) - return self.get_facts_from_collection(pools) - - def get_all_facts_by_type(self, key, type): - collection = getattr(self.api.tm.gtm.pools, key) - pools = collection.get_collection(**self.request_params) - return self.get_facts_from_collection(pools, type) - - def format_facts(self, pool, collection_type): - result = dict() - pool_dict = pool.to_dict() - result.update(self.format_string_facts(pool_dict)) - result.update(self.format_member_facts(pool)) - if collection_type: - result['type'] = collection_type - return camel_dict_to_snake_dict(result) - - def format_member_facts(self, pool): + @property + def virtual_servers(self): result = [] - if not 'items' in pool.membersReference: - return dict(members=[]) - for member in pool.membersReference['items']: - member_facts = self.format_string_facts(member) - result.append(member_facts) - return dict(members=result) + if self._values['virtual_servers'] is None or 'items' not in self._values['virtual_servers']: + return result + for item in self._values['virtual_servers']['items']: + self._remove_internal_keywords(item) + if 'disabled' in item: + if item['disabled'] in BOOLEANS_TRUE: + item['disabled'] = True + else: + item['disabled'] = False + if 'enabled' in item: + if item['enabled'] in BOOLEANS_TRUE: + item['enabled'] = True + else: + item['enabled'] = False + if 'fullPath' in item: + item['full_path'] = item.pop('fullPath') + if 'limitMaxBps' in item: + item['limit_max_bps'] = int(item.pop('limitMaxBps')) + if 'limitMaxBpsStatus' in item: + item['limit_max_bps_status'] = item.pop('limitMaxBpsStatus') + if 'limitMaxConnections' in item: + item['limit_max_connections'] = int(item.pop('limitMaxConnections')) + if 'limitMaxConnectionsStatus' in item: + item['limit_max_connections_status'] = item.pop('limitMaxConnectionsStatus') + if 'limitMaxPps' in item: + item['limit_max_pps'] = int(item.pop('limitMaxPps')) + if 'limitMaxPpsStatus' in item: + item['limit_max_pps_status'] = item.pop('limitMaxPpsStatus') + if 'translationAddress' in item: + item['translation_address'] = item.pop('translationAddress') + if 'translationPort' in item: + item['translation_port'] = int(item.pop('translation_port')) + result.append(item) + return result + @property + def limit_cpu_usage(self): + if self._values['limit_cpu_usage'] is None: + return None + return int(self._values['limit_cpu_usage']) + + @property + def limit_max_bps(self): + if self._values['limit_max_bps'] is None: + return None + return int(self._values['limit_max_bps']) + + @property + def limit_max_connections(self): + if self._values['limit_max_connections'] is None: + return None + return int(self._values['limit_max_connections']) + + @property + def limit_max_pps(self): + if self._values['limit_max_pps'] is None: + return None + return int(self._values['limit_max_pps']) + + @property + def limit_mem_available(self): + if self._values['limit_mem_available'] is None: + return None + return int(self._values['limit_mem_available']) + + +class PoolFactManager(BaseManager): + def exec_module(self): + if self.version_is_less_than_12(): + manager = self.get_manager('untyped') + else: + manager = self.get_manager('typed') + facts = manager.exec_module() + result = dict(pool=facts) + return result -class BigIpGtmFactsWideIps(BigIpGtmFactsCommon): - def __init__(self, *args, **kwargs): - super(BigIpGtmFactsWideIps, self).__init__() - self.params = kwargs + def get_manager(self, type): + if type == 'typed': + return TypedPoolFactManager(self.client) + elif type == 'untyped': + return UntypedPoolFactManager(self.client) - def get_facts(self): - self.api = self.connect_to_bigip(**self.params) - return self.get_facts_from_device() - def get_facts_from_device(self): - try: - if self.is_version_less_than_12(): - return self.get_facts_without_types() - else: - return self.get_facts_with_types() - except iControlUnexpectedHTTPError as e: - raise F5ModuleError(str(e)) +class TypedPoolFactManager(TypedManager): + def __init__(self, client): + super(TypedPoolFactManager, self).__init__(client) + self.want = PoolParameters(self.client.module.params) - def get_facts_with_types(self): - result = [] - for key, type in self.gtm_types.items(): - facts = self.get_all_facts_by_type(key, type) - if facts: - result.append(facts) + def read_facts(self, collection): + results = [] + collection = self.read_collection_from_device(collection) + for resource in collection: + attrs = resource.attrs + attrs['stats'] = self.read_stats_from_device(resource) + params = PoolParameters(attrs) + results.append(params) + return results + + def read_collection_from_device(self, collection_name): + pools = self.client.api.tm.gtm.pools + collection = getattr(pools, collection_name) + result = collection.get_collection( + requests_params=dict( + params='expandSubcollections=true' + ) + ) return result - def get_facts_without_types(self): - wideips = self.api.tm.gtm.wideips.get_collection( - **self.request_params + +class UntypedPoolFactManager(UntypedManager): + def __init__(self, client): + super(UntypedPoolFactManager, self).__init__(client) + self.want = PoolParameters(self.client.module.params) + + def read_facts(self): + results = [] + collection = self.read_collection_from_device() + for resource in collection: + attrs = resource.attrs + attrs['stats'] = self.read_stats_from_device(resource) + params = PoolParameters(attrs) + results.append(params) + return results + + def read_collection_from_device(self): + result = self.client.api.tm.gtm.pools.get_collection( + requests_params=dict( + params='expandSubcollections=true' + ) ) - return self.get_facts_from_collection(wideips) - - def get_all_facts_by_type(self, key, type): - collection = getattr(self.api.tm.gtm.wideips, key) - wideips = collection.get_collection(**self.request_params) - return self.get_facts_from_collection(wideips, type) - - def format_facts(self, wideip, collection_type): - result = dict() - wideip_dict = wideip.to_dict() - result.update(self.format_string_facts(wideip_dict)) - result.update(self.format_pool_facts(wideip)) - if collection_type: - result['type'] = collection_type - return camel_dict_to_snake_dict(result) - - def format_pool_facts(self, wideip): - result = [] - if not hasattr(wideip, 'pools'): - return dict(pools=[]) - for pool in wideip.pools: - pool_facts = self.format_string_facts(pool) - result.append(pool_facts) - return dict(pools=result) + return result -class BigIpGtmFactsVirtualServers(BigIpGtmFactsCommon): - def __init__(self, *args, **kwargs): - super(BigIpGtmFactsVirtualServers, self).__init__() - self.params = kwargs +class WideIpFactManager(BaseManager): + def exec_module(self): + if self.version_is_less_than_12(): + manager = self.get_manager('untyped') + else: + manager = self.get_manager('typed') + facts = manager.exec_module() + result = dict(wide_ip=facts) + return result - def get_facts(self): - try: - self.api = self.connect_to_bigip(**self.params) - return self.get_facts_from_device() - except iControlUnexpectedHTTPError as e: - raise F5ModuleError(str(e)) - - def get_facts_from_device(self): - servers = self.api.tm.gtm.servers.get_collection( - **self.request_params + def get_manager(self, type): + if type == 'typed': + return TypedWideIpFactManager(self.client) + elif type == 'untyped': + return UntypedWideIpFactManager(self.client) + + +class TypedWideIpFactManager(TypedManager): + def __init__(self, client): + super(TypedWideIpFactManager, self).__init__(client) + self.want = WideIpParameters(self.client.module.params) + + def read_facts(self, collection): + results = [] + collection = self.read_collection_from_device(collection) + for resource in collection: + attrs = resource.attrs + params = WideIpParameters(attrs) + results.append(params) + return results + + def read_collection_from_device(self, collection_name): + wideips = self.client.api.tm.gtm.wideips + collection = getattr(wideips, collection_name) + result = collection.get_collection( + requests_params=dict( + params='expandSubcollections=true' + ) ) - return self.get_facts_from_collection(servers) + return result - def format_facts(self, server, collection_type=None): - result = dict() - server_dict = server.to_dict() - result.update(self.format_string_facts(server_dict)) - result.update(self.format_address_facts(server)) - result.update(self.format_virtual_server_facts(server)) - return camel_dict_to_snake_dict(result) - def format_address_facts(self, server): - result = [] - if not hasattr(server, 'addresses'): - return dict(addresses=[]) - for address in server.addresses: - address_facts = self.format_string_facts(address) - result.append(address_facts) - return dict(addresses=result) - - def format_virtual_server_facts(self, server): - result = [] - if not 'items' in server.virtualServersReference: - return dict(virtual_servers=[]) - for server in server.virtualServersReference['items']: - server_facts = self.format_string_facts(server) - result.append(server_facts) - return dict(virtual_servers=result) - -class BigIpGtmFactsManager(object): - def __init__(self, *args, **kwargs): - self.params = kwargs - self.api = None - - def get_facts(self): - result = dict() - facts = dict() - - if 'pool' in self.params['include']: - facts['pool'] = self.get_pool_facts() - if 'wide_ip' in self.params['include']: - facts['wide_ip'] = self.get_wide_ip_facts() - if 'virtual_server' in self.params['include']: - facts['virtual_server'] = self.get_virtual_server_facts() - - result.update(**facts) - result.update(dict(changed=True)) +class UntypedWideIpFactManager(UntypedManager): + def __init__(self, client): + super(UntypedWideIpFactManager, self).__init__(client) + self.want = WideIpParameters(self.client.module.params) + + def read_facts(self): + results = [] + collection = self.read_collection_from_device() + for resource in collection: + attrs = resource.attrs + params = WideIpParameters(attrs) + results.append(params) + return results + + def read_collection_from_device(self): + result = self.client.api.tm.gtm.wideips.get_collection( + requests_params=dict( + params='expandSubcollections=true' + ) + ) return result - def get_pool_facts(self): - pools = BigIpGtmFactsPools(**self.params) - return pools.get_facts() - def get_wide_ip_facts(self): - wide_ips = BigIpGtmFactsWideIps(**self.params) - return wide_ips.get_facts() +class ServerFactManager(UntypedManager): + def __init__(self, client): + super(ServerFactManager, self).__init__(client) + self.want = ServerParameters(self.client.module.params) - def get_virtual_server_facts(self): - wide_ips = BigIpGtmFactsVirtualServers(**self.params) - return wide_ips.get_facts() + def exec_module(self): + facts = super(ServerFactManager, self).exec_module() + result = dict(server=facts, virtual_server=facts) + return result + def read_facts(self): + results = [] + collection = self.read_collection_from_device() + for resource in collection: + attrs = resource.attrs + params = WideIpParameters(attrs) + results.append(params) + return results -class BigIpGtmFactsModuleConfig(object): - def __init__(self): - self.argument_spec = dict() - self.meta_args = dict() - self.supports_check_mode = False - self.valid_includes = ['pool', 'wide_ip', 'virtual_server'] - self.initialize_meta_args() - self.initialize_argument_spec() + def read_collection_from_device(self): + result = self.client.api.tm.gtm.servers.get_collection( + requests_params=dict( + params='expandSubcollections=true' + ) + ) + return result - def initialize_meta_args(self): - args = dict( - include=dict(type='list', required=True), - filter=dict(type='str', required=False) + +class ModuleManager(object): + def __init__(self, client): + self.client = client + self.want = Parameters(self.client.module.params) + + def exec_module(self): + if not self.gtm_provisioned(): + raise F5ModuleError( + "GTM must be provisioned to use this module." + ) + + if 'all' in self.want.include: + names = ['pool', 'wide_ip', 'server'] + else: + names = self.want.include + # The virtual_server parameter is deprecated + if 'virtual_server' in names: + names.append('server') + names.remove('virtual_server') + managers = [self.get_manager(name) for name in names] + result = self.execute_managers(managers) + if result: + result['changed'] = True + else: + result['changed'] = False + self._announce_deprecations() + return result + + def _announce_deprecations(self): + warnings = [] + if self.want: + warnings += self.want._values.get('__warnings', []) + for warning in warnings: + self.client.module.deprecate( + msg=warning['msg'], + version=warning['version'] + ) + + def execute_managers(self, managers): + results = dict() + for manager in managers: + result = manager.exec_module() + results.update(result) + return results + + def get_manager(self, which): + if 'pool' == which: + return PoolFactManager(self.client) + if 'wide_ip' == which: + return WideIpFactManager(self.client) + if 'server' == which: + return ServerFactManager(self.client) + + def gtm_provisioned(self): + resource = self.client.api.tm.sys.dbs.db.load( + name='provisioned.cpu.gtm' ) - self.meta_args = args + if int(resource.value) == 0: + return False + return True - def initialize_argument_spec(self): - self.argument_spec = f5_argument_spec() - self.argument_spec.update(self.meta_args) - def create(self): - return AnsibleModule( - argument_spec=self.argument_spec, - supports_check_mode=self.supports_check_mode +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = False + self.argument_spec = dict( + include=dict(type='list', required=True), + filter=dict(type='str', required=False) ) + self.f5_product_name = 'bigip' def main(): if not HAS_F5SDK: raise F5ModuleError("The python f5-sdk module is required") - config = BigIpGtmFactsModuleConfig() - module = config.create() + spec = ArgumentSpec() - try: - obj = BigIpGtmFactsManager( - check_mode=module.check_mode, **module.params - ) - result = obj.get_facts() + client = AnsibleF5Client( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + f5_product_name=spec.f5_product_name, + ) - module.exit_json(**result) + try: + mm = ModuleManager(client) + results = mm.exec_module() + client.module.exit_json(**results) except F5ModuleError as e: - module.fail_json(msg=str(e)) + client.module.fail_json(msg=str(e)) -from ansible.module_utils.basic import * -from ansible.module_utils.ec2 import camel_dict_to_snake_dict -from ansible.module_utils.f5_utils import * if __name__ == '__main__': main() diff --git a/test/sanity/pep8/legacy-files.txt b/test/sanity/pep8/legacy-files.txt index 63f66226ef2..dc9ed820fd3 100644 --- a/test/sanity/pep8/legacy-files.txt +++ b/test/sanity/pep8/legacy-files.txt @@ -206,7 +206,6 @@ lib/ansible/modules/network/eos/eos_eapi.py lib/ansible/modules/network/eos/eos_facts.py lib/ansible/modules/network/eos/eos_system.py lib/ansible/modules/network/eos/eos_user.py -lib/ansible/modules/network/f5/bigip_gtm_facts.py lib/ansible/modules/network/f5/bigip_virtual_server.py lib/ansible/modules/network/fortios/fortios_config.py lib/ansible/modules/network/fortios/fortios_ipv4_policy.py diff --git a/test/units/modules/network/f5/fixtures/load_gtm_pool_a_collection.json b/test/units/modules/network/f5/fixtures/load_gtm_pool_a_collection.json new file mode 100644 index 00000000000..7ed5344503e --- /dev/null +++ b/test/units/modules/network/f5/fixtures/load_gtm_pool_a_collection.json @@ -0,0 +1,44 @@ +{ + "kind": "tm:gtm:pool:a:acollectionstate", + "selfLink": "https://localhost/mgmt/tm/gtm/pool/a?expandSubcollections=true&ver=13.0.0", + "items": [ + { + "kind": "tm:gtm:pool:a:astate", + "name": "foo.pool", + "partition": "Common", + "fullPath": "/Common/foo.pool", + "generation": 216, + "selfLink": "https://localhost/mgmt/tm/gtm/pool/a/~Common~foo.pool?ver=13.0.0", + "alternateMode": "round-robin", + "dynamicRatio": "disabled", + "enabled": true, + "fallbackIp": "any", + "fallbackMode": "return-to-dns", + "limitMaxBps": 0, + "limitMaxBpsStatus": "disabled", + "limitMaxConnections": 0, + "limitMaxConnectionsStatus": "disabled", + "limitMaxPps": 0, + "limitMaxPpsStatus": "disabled", + "loadBalancingMode": "round-robin", + "manualResume": "disabled", + "maxAnswersReturned": 1, + "monitor": "default", + "qosHitRatio": 5, + "qosHops": 0, + "qosKilobytesSecond": 3, + "qosLcs": 30, + "qosPacketRate": 1, + "qosRtt": 50, + "qosTopology": 0, + "qosVsCapacity": 0, + "qosVsScore": 0, + "ttl": 30, + "verifyMemberAvailability": "enabled", + "membersReference": { + "link": "https://localhost/mgmt/tm/gtm/pool/a/~Common~foo.pool/members?ver=13.0.0", + "isSubcollection": true + } + } + ] +} diff --git a/test/units/modules/network/f5/fixtures/load_gtm_pool_a_example_stats.json b/test/units/modules/network/f5/fixtures/load_gtm_pool_a_example_stats.json new file mode 100644 index 00000000000..70388c91440 --- /dev/null +++ b/test/units/modules/network/f5/fixtures/load_gtm_pool_a_example_stats.json @@ -0,0 +1,48 @@ +{ + "kind": "tm:gtm:pool:a:astats", + "generation": 216, + "selfLink": "https://localhost/mgmt/tm/gtm/pool/a/~Common~foo.pool/stats?ver=13.0.0", + "entries": { + "https://localhost/mgmt/tm/gtm/pool/a/~Common~foo.pool/~Common~foo.pool:A/stats": { + "nestedStats": { + "kind": "tm:gtm:pool:a:astats", + "selfLink": "https://localhost/mgmt/tm/gtm/pool/a/~Common~foo.pool/~Common~foo.pool:A/stats?ver=13.0.0", + "entries": { + "alternate": { + "value": 0 + }, + "dropped": { + "value": 0 + }, + "fallback": { + "value": 0 + }, + "tmName": { + "description": "/Common/foo.pool" + }, + "poolType": { + "description": "A" + }, + "preferred": { + "value": 0 + }, + "returnFromDns": { + "value": 0 + }, + "returnToDns": { + "value": 0 + }, + "status.availabilityState": { + "description": "offline" + }, + "status.enabledState": { + "description": "enabled" + }, + "status.statusReason": { + "description": "No enabled pool members available" + } + } + } + } + } +} diff --git a/test/units/modules/network/f5/test_bigip_gtm_facts.py b/test/units/modules/network/f5/test_bigip_gtm_facts.py new file mode 100644 index 00000000000..e4d2b891170 --- /dev/null +++ b/test/units/modules/network/f5/test_bigip_gtm_facts.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +# +# 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) +__metaclass__ = type + +import os +import json +import sys + +from nose.plugins.skip import SkipTest +if sys.version_info < (2, 7): + raise SkipTest("F5 Ansible modules require Python >= 2.7") + +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.six import iteritems + +try: + from library.bigip_gtm_facts import Parameters + from library.bigip_gtm_facts import ServerParameters + from library.bigip_gtm_facts import PoolParameters + from library.bigip_gtm_facts import WideIpParameters + from library.bigip_gtm_facts import ModuleManager + from library.bigip_gtm_facts import ServerFactManager + from library.bigip_gtm_facts import PoolFactManager + from library.bigip_gtm_facts import TypedPoolFactManager + from library.bigip_gtm_facts import UntypedPoolFactManager + from library.bigip_gtm_facts import WideIpFactManager + from library.bigip_gtm_facts import TypedWideIpFactManager + from library.bigip_gtm_facts import UntypedWideIpFactManager + from library.bigip_gtm_facts import ArgumentSpec + from ansible.module_utils.f5_utils import iControlUnexpectedHTTPError + from f5.bigip.tm.gtm.pool import A + from f5.utils.responses.handlers import Stats +except ImportError: + try: + from ansible.modules.network.f5.bigip_gtm_pool import Parameters + from ansible.modules.network.f5.bigip_gtm_pool import ServerParameters + from ansible.modules.network.f5.bigip_gtm_pool import PoolParameters + from ansible.modules.network.f5.bigip_gtm_pool import WideIpParameters + from ansible.modules.network.f5.bigip_gtm_pool import ModuleManager + from ansible.modules.network.f5.bigip_gtm_pool import ServerFactManager + from ansible.modules.network.f5.bigip_gtm_pool import PoolFactManager + from ansible.modules.network.f5.bigip_gtm_pool import TypedPoolFactManager + from ansible.modules.network.f5.bigip_gtm_pool import UntypedPoolFactManager + from ansible.modules.network.f5.bigip_gtm_pool import WideIpFactManager + from ansible.modules.network.f5.bigip_gtm_pool import TypedWideIpFactManager + from ansible.modules.network.f5.bigip_gtm_pool import UntypedWideIpFactManager + from ansible.modules.network.f5.bigip_gtm_pool import ArgumentSpec + from ansible.module_utils.f5_utils import iControlUnexpectedHTTPError + from f5.bigip.tm.gtm.pool import A + from f5.utils.responses.handlers import Stats + except ImportError: + raise SkipTest("F5 Ansible modules require the f5-sdk Python library") + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +def 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 FakeStatResource(object): + def __init__(self, obj): + self.entries = obj + + +class FakeARecord(A): + def __init__(self, *args, **kwargs): + attrs = kwargs.pop('attrs', {}) + for key, value in iteritems(attrs): + setattr(self, key, value) + + +class TestParameters(unittest.TestCase): + def test_module_parameters(self): + args = dict( + include=['pool'], + filter='name.*' + ) + p = Parameters(args) + assert p.include == ['pool'] + assert p.filter == 'name.*' + + +@patch('ansible.module_utils.f5_utils.AnsibleF5Client._get_mgmt_root', + return_value=True) +class TestManager(unittest.TestCase): + + def setUp(self): + self.spec = ArgumentSpec() + + def test_get_typed_pool_facts(self, *args): + set_module_args(dict( + include='pool', + password='passsword', + server='localhost', + user='admin' + )) + + fixture1 = load_fixture('load_gtm_pool_a_collection.json') + fixture2 = load_fixture('load_gtm_pool_a_example_stats.json') + collection = [FakeARecord(attrs=x) for x in fixture1['items']] + stats = Stats(FakeStatResource(fixture2['entries'])) + + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + + # Override methods in the specific type of manager + tfm = TypedPoolFactManager(client) + tfm.read_collection_from_device = Mock(return_value=collection) + tfm.read_stats_from_device = Mock(return_value=stats.stat) + + tm = PoolFactManager(client) + tm.version_is_less_than_12 = Mock(return_value=False) + tm.get_manager = Mock(return_value=tfm) + + # Override methods to force specific logic in the module to happen + mm = ModuleManager(client) + mm.get_manager = Mock(return_value=tm) + mm.gtm_provisioned = Mock(return_value=True) + + results = mm.exec_module() + + assert results['changed'] is True + assert 'pool' in results + assert len(results['pool']) > 0 + assert 'load_balancing_mode' in results['pool'][0]