diff --git a/lib/ansible/module_utils/network/f5/bigip.py b/lib/ansible/module_utils/network/f5/bigip.py index 63887fac5d7..8fde9fc7a75 100644 --- a/lib/ansible/module_utils/network/f5/bigip.py +++ b/lib/ansible/module_utils/network/f5/bigip.py @@ -19,31 +19,87 @@ except ImportError: try: from library.module_utils.network.f5.common import F5BaseClient from library.module_utils.network.f5.common import F5ModuleError + from library.module_utils.network.f5.icontrol import iControlRestSession except ImportError: from ansible.module_utils.network.f5.common import F5BaseClient from ansible.module_utils.network.f5.common import F5ModuleError + from ansible.module_utils.network.f5.icontrol import iControlRestSession class F5Client(F5BaseClient): + def __init__(self, *args, **kwargs): + super(F5Client, self).__init__(*args, **kwargs) + self.provider = self.merge_provider_params() + @property def api(self): + exc = None if self._client: return self._client - for x in range(0, 10): + + for x in range(0, 60): try: result = ManagementRoot( - self.params['server'], - self.params['user'], - self.params['password'], - port=self.params['server_port'], - verify=self.params['validate_certs'], + self.provider['server'], + self.provider['user'], + self.provider['password'], + port=self.provider['server_port'], + verify=self.provider['validate_certs'], token='tmos' ) self._client = result return self._client - except Exception: - time.sleep(3) - raise F5ModuleError( - 'Unable to connect to {0} on port {1}. ' - 'Is "validate_certs" preventing this?'.format(self.params['server'], self.params['server_port']) + except Exception as ex: + exc = ex + time.sleep(1) + error = 'Unable to connect to {0} on port {1}.'.format( + self.params['server'], self.params['server_port'] + ) + + if exc is not None: + error += ' The reported error was "{0}".'.format(str(exc)) + raise F5ModuleError(error) + + +class F5RestClient(F5BaseClient): + def __init__(self, *args, **kwargs): + super(F5RestClient, self).__init__(*args, **kwargs) + self.provider = self.merge_provider_params() + + @property + def api(self): + exc = None + if self._client: + return self._client + + for x in range(0, 10): + try: + url = "https://{0}:{1}/mgmt/shared/authn/login".format( + self.provider['server'], self.provider['server_port'] + ) + payload = { + 'username': self.provider['user'], + 'password': self.provider['password'], + 'loginProviderName': self.provider['auth_provider'] + } + session = iControlRestSession() + session.verify = self.provider['validate_certs'] + response = session.post(url, json=payload) + + if response.status_code not in [200]: + raise F5ModuleError('{0} Unexpected Error: {1} for uri: {2}\nText: {3}'.format( + response.status_code, response.reason, response.url, response._content + )) + + session.headers['X-F5-Auth-Token'] = response.json()['token']['token'] + self._client = session + return self._client + except Exception as ex: + exc = ex + time.sleep(1) + error = 'Unable to connect to {0} on port {1}.'.format( + self.params['server'], self.params['server_port'] ) + if exc is not None: + error += ' The reported error was "{0}".'.format(str(exc)) + raise F5ModuleError(error) diff --git a/lib/ansible/module_utils/network/f5/bigiq.py b/lib/ansible/module_utils/network/f5/bigiq.py index 458f2696fae..17f4ceeb8d3 100644 --- a/lib/ansible/module_utils/network/f5/bigiq.py +++ b/lib/ansible/module_utils/network/f5/bigiq.py @@ -19,31 +19,77 @@ except ImportError: try: from library.module_utils.network.f5.common import F5BaseClient from library.module_utils.network.f5.common import F5ModuleError + from library.module_utils.network.f5.common import is_ansible_debug + from library.module_utils.network.f5.icontrol import iControlRestSession except ImportError: from ansible.module_utils.network.f5.common import F5BaseClient from ansible.module_utils.network.f5.common import F5ModuleError + from ansible.module_utils.network.f5.common import is_ansible_debug + from ansible.module_utils.network.f5.icontrol import iControlRestSession class F5Client(F5BaseClient): @property def api(self): + exc = None if self._client: return self._client - for x in range(0, 10): + for x in range(0, 3): try: + server = self.params['provider']['server'] or self.params['server'] + user = self.params['provider']['user'] or self.params['user'] + password = self.params['provider']['password'] or self.params['password'] + server_port = self.params['provider']['server_port'] or self.params['server_port'] or 443 + validate_certs = self.params['provider']['validate_certs'] or self.params['validate_certs'] + result = ManagementRoot( - self.params['server'], - self.params['user'], - self.params['password'], - port=self.params['server_port'], - verify=self.params['validate_certs'], - token='local' + server, + user, + password, + port=server_port, + verify=validate_certs + ) + self._client = result + return self._client + except Exception as ex: + exc = ex + time.sleep(1) + error = 'Unable to connect to {0} on port {1}.'.format(self.params['server'], self.params['server_port']) + if exc is not None: + error += ' The reported error was "{0}".'.format(str(exc)) + raise F5ModuleError(error) + + +class F5RestClient(F5BaseClient): + @property + def api(self): + ex = None + if self._client: + return self._client + for x in range(0, 10): + try: + server = self.params['provider']['server'] or self.params['server'] + user = self.params['provider']['user'] or self.params['user'] + password = self.params['provider']['password'] or self.params['password'] + server_port = self.params['provider']['server_port'] or self.params['server_port'] or 443 + validate_certs = self.params['provider']['validate_certs'] or self.params['validate_certs'] + + # Should we import from module?? + # self.module.params['server'], + result = iControlRestSession( + server, + user, + password, + port=server_port, + verify=validate_certs, + auth_provider='local', + debug=is_ansible_debug(self.module) ) self._client = result return self._client - except Exception: - time.sleep(3) - raise F5ModuleError( - 'Unable to connect to {0} on port {1}. ' - 'Is "validate_certs" preventing this?'.format(self.params['server'], self.params['server_port']) - ) + except Exception as ex: + time.sleep(1) + error = 'Unable to connect to {0} on port {1}.'.format(self.params['server'], self.params['server_port']) + if ex is not None: + error += ' The reported error was "{0}".'.format(str(ex)) + raise F5ModuleError(error) diff --git a/lib/ansible/module_utils/network/f5/common.py b/lib/ansible/module_utils/network/f5/common.py index 0f6c2c5b62e..60c851d0902 100644 --- a/lib/ansible/module_utils/network/f5/common.py +++ b/lib/ansible/module_utils/network/f5/common.py @@ -6,6 +6,7 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type +import os import re from ansible.module_utils._text import to_text @@ -13,6 +14,7 @@ from ansible.module_utils.basic import env_fallback from ansible.module_utils.connection import exec_command from ansible.module_utils.network.common.utils import to_list, ComplexList from ansible.module_utils.six import iteritems +from ansible.module_utils.parsing.convert_bool import BOOLEANS_TRUE from collections import defaultdict try: @@ -28,7 +30,6 @@ f5_provider_spec = { ), 'server_port': dict( type='int', - default=443, fallback=(env_fallback, ['F5_SERVER_PORT']) ), 'user': dict( @@ -40,7 +41,6 @@ f5_provider_spec = { fallback=(env_fallback, ['F5_PASSWORD', 'ANSIBLE_NET_PASSWORD']) ), 'ssh_keyfile': dict( - fallback=(env_fallback, ['ANSIBLE_NET_SSH_KEYFILE']), type='path' ), 'validate_certs': dict( @@ -48,8 +48,8 @@ f5_provider_spec = { fallback=(env_fallback, ['F5_VALIDATE_CERTS']) ), 'transport': dict( - default='rest', - choices=['cli', 'rest'] + choices=['cli', 'rest'], + default='rest' ), 'timeout': dict(type='int'), } @@ -81,12 +81,10 @@ f5_top_spec = { 'server_port': dict( removed_in_version=2.9, type='int', - default=443, fallback=(env_fallback, ['F5_SERVER_PORT']) ), 'transport': dict( removed_in_version=2.9, - default='rest', choices=['cli', 'rest'] ) } @@ -107,8 +105,59 @@ def load_params(params): # Fully Qualified name (with the partition) def fqdn_name(partition, value): - if value is not None and not value.startswith('/'): - return '/{0}/{1}'.format(partition, value) + """This method is not used + + This was the original name of a method that was used throughout all + the F5 Ansible modules. This is now deprecated, and should be removed + in 2.9. All modules should be changed to use ``fq_name``. + + TODO(Remove in Ansible 2.9) + """ + return fq_name(partition, value) + + +def fq_name(partition, value): + """Returns a 'Fully Qualified' name + + A BIG-IP expects most names of resources to be in a fully-qualified + form. This means that both the simple name, and the partition need + to be combined. + + The Ansible modules, however, can accept (as names for several + resources) their name in the FQ format. This becomes an issue when + the FQ name and the partition are both specified as separate values. + + Consider the following examples. + + # Name not FQ + name: foo + partition: Common + + # Name FQ + name: /Common/foo + partition: Common + + This method will rectify the above situation and will, in both cases, + return the following for name. + + /Common/foo + + Args: + partition (string): The partition that you would want attached to + the name if the name has no partition. + value (string): The name that you want to attach a partition to. + This value will be returned unchanged if it has a partition + attached to it already. + Returns: + string: The fully qualified name, given the input parameters. + """ + if value is not None: + try: + int(value) + return '/{0}/{1}'.format(partition, value) + except (ValueError, TypeError): + if not value.startswith('/'): + return '/{0}/{1}'.format(partition, value) return value @@ -137,7 +186,8 @@ def run_commands(module, commands, check_rc=True): rc, out, err = exec_command(module, cmd) if check_rc and rc != 0: raise F5ModuleError(to_text(err, errors='surrogate_then_replace')) - responses.append(to_text(out, errors='surrogate_then_replace')) + result = to_text(out, errors='surrogate_then_replace') + responses.append(result) return responses @@ -183,6 +233,101 @@ def is_valid_hostname(host): return result +def is_valid_fqdn(host): + """Reasonable attempt at validating a hostname + + Compiled from various paragraphs outlined here + https://tools.ietf.org/html/rfc3696#section-2 + https://tools.ietf.org/html/rfc1123 + + Notably, + * Host software MUST handle host names of up to 63 characters and + SHOULD handle host names of up to 255 characters. + * The "LDH rule", after the characters that it permits. (letters, digits, hyphen) + * If the hyphen is used, it is not permitted to appear at + either the beginning or end of a label + + :param host: + :return: + """ + if len(host) > 255: + return False + host = host.rstrip(".") + allowed = re.compile(r'(?!-)[A-Z0-9-]{1,63}(? 1: + return True + return False + + +def dict2tuple(items): + """Convert a dictionary to a list of tuples + + This method is used in cases where dictionaries need to be compared. Due + to dictionaries inherently having no order, it is easier to compare list + of tuples because these lists can be converted to sets. + + This conversion only supports dicts of simple values. Do not give it dicts + that contain sub-dicts. This will not give you the result you want when using + the returned tuple for comparison. + + Args: + items (dict): The dictionary of items that should be converted + + Returns: + list: Returns a list of tuples upon success. Otherwise, an empty list. + """ + result = [] + for x in items: + tmp = [(str(k), str(v)) for k, v in iteritems(x)] + result += tmp + return result + + +def compare_dictionary(want, have): + """Performs a dictionary comparison + + Args: + want (dict): Dictionary to compare with second parameter. + have (dict): Dictionary to compare with first parameter. + + Returns: + bool: + :param have: + :return: + """ + if want == [] and have is None: + return None + if want is None: + return None + w = dict2tuple(want) + h = dict2tuple(have) + if set(w) == set(h): + return None + else: + return want + + +def is_ansible_debug(module): + if module._debug and module._verbosity >= 4: + return True + return False + + +def fail_json(module, ex, client=None): + if is_ansible_debug(module) and client: + module.fail_json(msg=str(ex), __f5debug__=client.api.debug_output) + module.fail_json(msg=str(ex)) + + +def exit_json(module, results, client=None): + if is_ansible_debug(module) and client: + results['__f5debug__'] = client.api.debug_output + module.exit_json(**results) + + class Noop(object): """Represent no-operation required @@ -200,6 +345,7 @@ class Noop(object): class F5BaseClient(object): def __init__(self, *args, **kwargs): self.params = kwargs + self.module = kwargs.get('module', None) load_params(self.params) self._client = None @@ -222,7 +368,73 @@ class F5BaseClient(object): :return: :raises iControlUnexpectedHTTPError """ - self._client = self.mgmt + self._client = None + + def merge_provider_params(self): + result = dict() + + provider = self.params.get('provider', {}) + + if provider.get('server', None): + result['server'] = provider.get('server', None) + elif self.params.get('server', None): + result['server'] = self.params.get('server', None) + elif os.environ.get('F5_SERVER', None): + result['server'] = os.environ.get('F5_SERVER', None) + + if provider.get('server_port', None): + result['server_port'] = provider.get('server_port', None) + elif self.params.get('server_port', None): + result['server_port'] = self.params.get('server_port', None) + elif os.environ.get('F5_SERVER_PORT', None): + result['server_port'] = os.environ.get('F5_SERVER_PORT', None) + else: + result['server_port'] = 443 + + if provider.get('validate_certs', None) is not None: + result['validate_certs'] = provider.get('validate_certs', None) + elif self.params.get('validate_certs', None) is not None: + result['validate_certs'] = self.params.get('validate_certs', None) + elif os.environ.get('F5_VALIDATE_CERTS', None) is not None: + result['validate_certs'] = os.environ.get('F5_VALIDATE_CERTS', None) + else: + result['validate_certs'] = True + + if provider.get('auth_provider', None): + result['auth_provider'] = provider.get('auth_provider', None) + elif self.params.get('auth_provider', None): + result['auth_provider'] = self.params.get('auth_provider', None) + else: + result['auth_provider'] = 'tmos' + + if provider.get('user', None): + result['user'] = provider.get('user', None) + elif self.params.get('user', None): + result['user'] = self.params.get('user', None) + elif os.environ.get('F5_USER', None): + result['user'] = os.environ.get('F5_USER', None) + elif os.environ.get('ANSIBLE_NET_USERNAME', None): + result['user'] = os.environ.get('ANSIBLE_NET_USERNAME', None) + else: + result['user'] = True + + if provider.get('password', None): + result['password'] = provider.get('password', None) + elif self.params.get('user', None): + result['password'] = self.params.get('password', None) + elif os.environ.get('F5_PASSWORD', None): + result['password'] = os.environ.get('F5_PASSWORD', None) + elif os.environ.get('ANSIBLE_NET_PASSWORD', None): + result['password'] = os.environ.get('ANSIBLE_NET_PASSWORD', None) + else: + result['password'] = True + + if result['validate_certs'] in BOOLEANS_TRUE: + result['validate_certs'] = True + else: + result['validate_certs'] = False + + return result class AnsibleF5Parameters(object): diff --git a/lib/ansible/module_utils/network/f5/icontrol.py b/lib/ansible/module_utils/network/f5/icontrol.py new file mode 100644 index 00000000000..daf96c1c643 --- /dev/null +++ b/lib/ansible/module_utils/network/f5/icontrol.py @@ -0,0 +1,367 @@ +# -*- 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 sys + +from ansible.module_utils.urls import open_url, fetch_url +from ansible.module_utils.parsing.convert_bool import BOOLEANS +from ansible.module_utils.six import string_types +from ansible.module_utils.six import iteritems +from ansible.module_utils.urls import urllib_error +from ansible.module_utils._text import to_native +from ansible.module_utils.six import PY3 + +try: + import json as _json +except ImportError: + import simplejson as _json + +try: + from library.module_utils.network.f5.common import F5ModuleError +except ImportError: + from ansible.module_utils.network.f5.common import F5ModuleError + + +"""An F5 REST API URI handler. + +Use this module to make calls to an F5 REST server. It is influenced by the same +API that the Python ``requests`` tool uses, but the two are not the same, as the +library here is **much** more simple and targeted specifically to F5's needs. + +The ``requests`` design was chosen due to familiarity with the tool. Internals though +use Ansible native libraries. + +The means by which you should use it are similar to ``requests`` basic usage. + +Authentication is not handled for you automatically by this library, however it *is* +handled automatically for you in the supporting F5 module_utils code; specifically the +different product module_util files (bigip.py, bigiq.py, etc). + +Internal (non-module) usage of this library looks like this. + +``` +# Create a session instance +mgmt = iControlRestSession() +mgmt.verify = False + +server = '1.1.1.1' +port = 443 + +# Payload used for getting an initial authentication token +payload = { + 'username': 'admin', + 'password': 'secret', + 'loginProviderName': 'tmos' +} + +# Create URL to call, injecting server and port +url = f"https://{server}:{port}/mgmt/shared/authn/login" + +# Call the API +resp = session.post(url, json=payload) + +# View the response +print(resp.json()) + +# Update the session with the authentication token +session.headers['X-F5-Auth-Token'] = resp.json()['token']['token'] + +# Create another URL to call, injecting server and port +url = f"https://{server}:{port}/mgmt/tm/ltm/virtual/~Common~virtual1" + +# Call the API +resp = session.get(url) + +# View the details of a virtual payload +print(resp.json()) +``` +""" + + +class Request(object): + def __init__(self, method=None, url=None, headers=None, data=None, params=None, + auth=None, json=None): + self.method = method + self.url = url + self.headers = headers or {} + self.data = data or [] + self.json = json + self.params = params or {} + self.auth = auth + + def prepare(self): + p = PreparedRequest() + p.prepare( + method=self.method, + url=self.url, + headers=self.headers, + data=self.data, + json=self.json, + params=self.params, + ) + return p + + +class PreparedRequest(object): + def __init__(self): + self.method = None + self.url = None + self.headers = None + self.body = None + + def prepare(self, method=None, url=None, headers=None, data=None, params=None, json=None): + self.prepare_method(method) + self.prepare_url(url, params) + self.prepare_headers(headers) + self.prepare_body(data, json) + + def prepare_url(self, url, params): + self.url = url + + def prepare_method(self, method): + self.method = method + if self.method: + self.method = self.method.upper() + + def prepare_headers(self, headers): + self.headers = {} + if headers: + for k, v in iteritems(headers): + self.headers[k] = v + + def prepare_body(self, data, json=None): + body = None + content_type = None + + if not data and json is not None: + self.headers['Content-Type'] = 'application/json' + body = _json.dumps(json) + if not isinstance(body, bytes): + body = body.encode('utf-8') + + if data: + body = data + content_type = None + + if content_type and 'content-type' not in self.headers: + self.headers['Content-Type'] = content_type + + self.body = body + + +class Response(object): + def __init__(self): + self._content = None + self.status_code = None + self.headers = dict() + self.url = None + self.reason = None + self.request = None + + def json(self): + return _json.loads(self._content) + + +class iControlRestSession(object): + """Represents a session that communicates with a BigIP. + + Instantiate one of these when you want to communicate with an F5 REST + Server, it will handle F5-specific authentication. + + Pass an existing authentication token to the ``token`` argument to re-use + that token for authentication. Otherwise, token authentication is handled + automatically for you. + + On BIG-IQ, it may be necessary to pass the ``auth_provider`` argument if the + user has a different authentication handler configured. Otherwise, the system + defaults for the different products will be used. + """ + def __init__(self): + self.headers = self.default_headers() + self.verify = True + self.params = {} + self.auth = None + self.timeout = 30 + + def _normalize_headers(self, headers): + result = {} + result.update(dict((k.lower(), v) for k, v in headers)) + + # Don't be lossy, append header values for duplicate headers + # In Py2 there is nothing that needs done, py2 does this for us + if PY3: + temp_headers = {} + for name, value in headers: + # The same as above, lower case keys to match py2 behavior, and create more consistent results + name = name.lower() + if name in temp_headers: + temp_headers[name] = ', '.join((temp_headers[name], value)) + else: + temp_headers[name] = value + result.update(temp_headers) + return result + + def default_headers(self): + return { + 'connection': 'keep-alive', + 'accept': '*/*', + } + + def prepare_request(self, request): + headers = self.headers.copy() + params = self.params.copy() + + if request.headers is not None: + headers.update(request.headers) + if request.params is not None: + params.update(request.params) + + prepared = PreparedRequest() + prepared.prepare( + method=request.method, + url=request.url, + data=request.data, + json=request.json, + headers=headers, + params=params, + ) + return prepared + + def request(self, method, url, params=None, data=None, headers=None, auth=None, + timeout=None, verify=None, json=None): + request = Request( + method=method.upper(), + url=url, + headers=headers, + json=json, + data=data or {}, + params=params or {}, + auth=auth + ) + kwargs = dict( + timeout=timeout, + verify=verify + ) + prepared = self.prepare_request(request) + return self.send(prepared, **kwargs) + + def send(self, request, **kwargs): + response = Response() + + params = dict( + method=request.method, + data=request.body, + timeout=kwargs.get('timeout', None) or self.timeout, + headers=request.headers + ) + + try: + result = open_url(request.url, **params) + response._content = result.read() + response.status = result.getcode() + response.url = result.geturl() + response.msg = "OK (%s bytes)" % result.headers.get('Content-Length', 'unknown') + response.headers = self._normalize_headers(result.headers.items()) + response.request = request + except urllib_error.HTTPError as e: + try: + response._content = e.read() + except AttributeError: + response._content = '' + + response.reason = to_native(e) + response.status_code = e.code + return response + + def delete(self, url, **kwargs): + """Sends a HTTP DELETE command to an F5 REST Server. + + Use this method to send a DELETE command to an F5 product. + + Args: + url (string): URL to call. + data (bytes): An object specifying additional data to send to the server, + or ``None`` if no such data is needed. Currently HTTP requests are the + only ones that use data. The supported object types include bytes, + file-like objects, and iterables. + See https://docs.python.org/3/library/urllib.request.html#urllib.request.Request + \\*\\*kwargs (dict): Optional arguments to send to the request. + """ + return self.request('DELETE', url, **kwargs) + + def get(self, url, **kwargs): + """Sends a HTTP GET command to an F5 REST Server. + + Use this method to send a GET command to an F5 product. + + Args: + url (string): URL to call. + \\*\\*kwargs (dict): Optional arguments to send to the request. + """ + return self.request('GET', url, **kwargs) + + def patch(self, url, data=None, **kwargs): + """Sends a HTTP PATCH command to an F5 REST Server. + + Use this method to send a PATCH command to an F5 product. + + Args: + url (string): URL to call. + data (bytes): An object specifying additional data to send to the server, + or ``None`` if no such data is needed. Currently HTTP requests are the + only ones that use data. The supported object types include bytes, + file-like objects, and iterables. + See https://docs.python.org/3/library/urllib.request.html#urllib.request.Request + \\*\\*kwargs (dict): Optional arguments to send to the request. + """ + return self.request('PATCH', url, data=data, **kwargs) + + def post(self, url, data=None, json=None, **kwargs): + """Sends a HTTP POST command to an F5 REST Server. + + Use this method to send a POST command to an F5 product. + + Args: + url (string): URL to call. + data (dict): An object specifying additional data to send to the server, + or ``None`` if no such data is needed. Currently HTTP requests are the + only ones that use data. The supported object types include bytes, + file-like objects, and iterables. + See https://docs.python.org/3/library/urllib.request.html#urllib.request.Request + \\*\\*kwargs (dict): Optional arguments to the request. + """ + return self.request('POST', url, data=data, json=json, **kwargs) + + def put(self, url, data=None, **kwargs): + """Sends a HTTP PUT command to an F5 REST Server. + + Use this method to send a PUT command to an F5 product. + + Args: + url (string): URL to call. + data (bytes): An object specifying additional data to send to the server, + or ``None`` if no such data is needed. Currently HTTP requests are the + only ones that use data. The supported object types include bytes, + file-like objects, and iterables. + See https://docs.python.org/3/library/urllib.request.html#urllib.request.Request + \\*\\*kwargs (dict): Optional arguments to the request. + """ + return self.request('PUT', url, data=data, **kwargs) + + +def debug_prepared_request(url, method, headers, data=None): + result = "curl -k -X {0} {1}".format(method.upper(), url) + for k, v in iteritems(headers): + result = result + " -H '{0}: {1}'".format(k, v) + if any(v == 'application/json' for k, v in iteritems(headers)): + if data: + kwargs = _json.loads(data.decode('utf-8')) + result = result + " -d '" + _json.dumps(kwargs, sort_keys=True) + "'" + return result diff --git a/lib/ansible/module_utils/network/f5/iworkflow.py b/lib/ansible/module_utils/network/f5/iworkflow.py index 2b2e995795d..a7de5c1c41d 100644 --- a/lib/ansible/module_utils/network/f5/iworkflow.py +++ b/lib/ansible/module_utils/network/f5/iworkflow.py @@ -27,23 +27,31 @@ except ImportError: class F5Client(F5BaseClient): @property def api(self): + exc = None if self._client: return self._client - for x in range(0, 10): + for x in range(0, 3): try: + server = self.params['provider']['server'] or self.params['server'] + user = self.params['provider']['user'] or self.params['user'] + password = self.params['provider']['password'] or self.params['password'] + server_port = self.params['provider']['server_port'] or self.params['server_port'] or 443 + validate_certs = self.params['provider']['validate_certs'] or self.params['validate_certs'] + result = ManagementRoot( - self.params['server'], - self.params['user'], - self.params['password'], - port=self.params['server_port'], - verify=self.params['validate_certs'], + server, + user, + password, + port=server_port, + verify=validate_certs, token='local' ) self._client = result return self._client - except Exception: + except Exception as ex: + exc = ex time.sleep(3) - raise F5ModuleError( - 'Unable to connect to {0} on port {1}. ' - 'Is "validate_certs" preventing this?'.format(self.params['server'], self.params['server_port']) - ) + error = 'Unable to connect to {0} on port {1}.'.format(self.params['server'], self.params['server_port']) + if exc is not None: + error += ' The reported error was "{0}".'.format(str(exc)) + raise F5ModuleError(error) diff --git a/lib/ansible/module_utils/network/f5/legacy.py b/lib/ansible/module_utils/network/f5/legacy.py new file mode 100644 index 00000000000..bb2189c2bbc --- /dev/null +++ b/lib/ansible/module_utils/network/f5/legacy.py @@ -0,0 +1,121 @@ +# -*- 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 + +try: + import bigsuds + bigsuds_found = True +except ImportError: + bigsuds_found = False + + +from ansible.module_utils.basic import env_fallback + + +def f5_argument_spec(): + return dict( + server=dict( + type='str', + required=True, + fallback=(env_fallback, ['F5_SERVER']) + ), + user=dict( + type='str', + required=True, + fallback=(env_fallback, ['F5_USER']) + ), + password=dict( + type='str', + aliases=['pass', 'pwd'], + required=True, + no_log=True, + fallback=(env_fallback, ['F5_PASSWORD']) + ), + validate_certs=dict( + default='yes', + type='bool', + fallback=(env_fallback, ['F5_VALIDATE_CERTS']) + ), + server_port=dict( + type='int', + default=443, + fallback=(env_fallback, ['F5_SERVER_PORT']) + ), + state=dict( + type='str', + default='present', + choices=['present', 'absent'] + ), + partition=dict( + type='str', + default='Common', + fallback=(env_fallback, ['F5_PARTITION']) + ) + ) + + +def f5_parse_arguments(module): + 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'") + + return ( + module.params['server'], + module.params['user'], + module.params['password'], + module.params['state'], + module.params['partition'], + module.params['validate_certs'], + module.params['server_port'] + ) + + +def bigip_api(bigip, user, password, validate_certs, port=443): + try: + if bigsuds.__version__ >= '1.0.4': + api = bigsuds.BIGIP(hostname=bigip, username=user, password=password, verify=validate_certs, port=port) + elif bigsuds.__version__ == '1.0.3': + api = bigsuds.BIGIP(hostname=bigip, username=user, password=password, verify=validate_certs) + else: + api = bigsuds.BIGIP(hostname=bigip, username=user, password=password) + except TypeError: + # bigsuds < 1.0.3, no verify param + if validate_certs: + # Note: verified we have SSLContext when we parsed params + api = bigsuds.BIGIP(hostname=bigip, username=user, password=password) + else: + import ssl + if hasattr(ssl, 'SSLContext'): + # Really, you should never do this. It disables certificate + # verification *globally*. But since older bigip libraries + # don't give us a way to toggle verification we need to + # disable it at the global level. + # From https://www.python.org/dev/peps/pep-0476/#id29 + ssl._create_default_https_context = ssl._create_unverified_context + api = bigsuds.BIGIP(hostname=bigip, username=user, password=password) + + return api + + +# Fully Qualified name (with the partition) +def fq_name(partition, name): + if name is not None and not name.startswith('/'): + return '/%s/%s' % (partition, name) + return name + + +# Fully Qualified name (with partition) for a list +def fq_list_names(partition, list_names): + if list_names is None: + return None + return map(lambda x: fq_name(partition, x), list_names) diff --git a/lib/ansible/modules/network/f5/bigip_data_group.py b/lib/ansible/modules/network/f5/bigip_data_group.py new file mode 100644 index 00000000000..7263ac9fd4a --- /dev/null +++ b/lib/ansible/modules/network/f5/bigip_data_group.py @@ -0,0 +1,1069 @@ +#!/usr/bin/python +# -*- 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 + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: bigip_data_group +short_description: Manage data groups on a BIG-IP +description: + - Allows for managing data groups on a BIG-IP. Data groups provide a way to store collections + of values on a BIG-IP for later use in things such as LTM rules, iRules, and ASM policies. +version_added: 2.6 +options: + name: + description: + - Specifies the name of the data group. + required: True + type: + description: + - The type of records in this data group. + - This parameter is especially important because it causes BIG-IP to store your data + in different ways so-as to optimize access to it. For example, it would be wrong + to specify a list of records containing IP addresses, but label them as a C(string) + type. + - This value cannot be changed once the data group is created. + choices: + - address + - addr + - ip + - string + - str + - integer + - int + default: string + internal: + description: + - The type of this data group. + - You should only consider setting this value in cases where you know exactly what + you're doing, B(or), you are working with a pre-existing internal data group. + - Be aware that if you deliberately force this parameter to C(yes), and you have a + either a large number of records or a large total records size, this large amount + of data will be reflected in your BIG-IP configuration. This can lead to B(long) + system configuration load times due to needing to parse and verify the large + configuration. + - There is a limit of either 4 megabytes or 65,000 records (whichever is more restrictive) + for uploads when this parameter is C(yes). + - This value cannot be changed once the data group is created. + type: bool + default: no + external_file_name: + description: + - When creating a new data group, this specifies the file name that you want to give an + external data group file on the BIG-IP. + - This parameter is ignored when C(internal) is C(yes). + - This parameter can be used to select an existing data group file to use with an + existing external data group. + - If this value is not provided, it will be given the value specified in C(name) and, + therefore, match the name of the data group. + - This value may only contain letters, numbers, underscores, dashes, or a period. + records: + description: + - Specifies the records that you want to add to a data group. + - If you have a large number of records, it is recommended that you use C(records_content) + instead of typing all those records here. + - The technical limit of either 1. the number of records, or 2. the total size of all + records, varies with the size of the total resources on your system; in particular, + RAM. + - When C(internal) is C(no), at least one record must be specified in either C(records) + or C(records_content). + suboptions: + key: + description: + - The key describing the record in the data group. + - Your key will be used for validation of the C(type) parameter to this module. + required: True + value: + description: + - The value of the key describing the record in the data group. + records_src: + description: + - Path to a file with records in it. + - The file should be well-formed. This means that it includes records, one per line, + that resemble the following format "key separator value". For example, C(foo := bar). + - BIG-IP is strict about this format, but this module is a bit more lax. It will allow + you to include arbitrary amounts (including none) of empty space on either side of + the separator. For an illustration of this, see the Examples section. + - Record keys are limited in length to no more than 65520 characters. + - Values of record keys are limited in length to no more than 65520 characters. + - The total number of records you can have in your BIG-IP is limited by the memory + of the BIG-IP. + - The format of this content is slightly different depending on whether you specify + a C(type) of C(address), C(integer), or C(string). See the examples section for + examples of the different types of payload formats that are expected in your data + group file. + - When C(internal) is C(no), at least one record must be specified in either C(records) + or C(records_content). + separator: + description: + - When specifying C(records_content), this is the string of characters that will + be used to break apart entries in the C(records_content) into key/value pairs. + - By default, this parameter's value is C(:=). + - This value cannot be changed once it is set. + - This parameter is only relevant when C(internal) is C(no). It will be ignored + otherwise. + default: ":=" + delete_data_group_file: + description: + - When C(yes), will ensure that the remote data group file is deleted. + - This parameter is only relevant when C(state) is C(absent) and C(internal) is C(no). + default: no + type: bool + partition: + description: + - Device partition to manage resources on. + default: Common + state: + description: + - When C(state) is C(present), ensures the data group exists. + - When C(state) is C(absent), ensures that the data group is removed. + choices: + - present + - absent + default: present +extends_documentation_fragment: f5 +author: + - Tim Rupp (@caphrim007) +''' + +EXAMPLES = r''' +- name: Create a data group of addresses + bigip_data_group: + name: foo + password: secret + server: lb.mydomain.com + state: present + user: admin + records: + - key: 0.0.0.0/32 + value: External_NAT + - key: 10.10.10.10 + value: No_NAT + type: address + delegate_to: localhost + +- name: Create a data group of strings + bigip_data_group: + name: foo + password: secret + server: lb.mydomain.com + state: present + user: admin + records: + - key: caddy + value: "" + - key: cafeteria + value: "" + - key: cactus + value: "" + type: string + delegate_to: localhost + +- name: Create a data group of IP addresses from a file + bigip_data_group: + name: foo + password: secret + server: lb.mydomain.com + state: present + user: admin + records_src: /path/to/dg-file + type: address + delegate_to: localhost + +- name: Update an existing internal data group of strings + bigip_data_group: + name: foo + password: secret + server: lb.mydomain.com + state: present + internal: yes + user: admin + records: + - key: caddy + value: "" + - key: cafeteria + value: "" + - key: cactus + value: "" + delegate_to: localhost + +- name: Show the data format expected for records_content - address 1 + copy: + dest: /path/to/addresses.txt + content: | + network 10.0.0.0 prefixlen 8 := "Network1", + network 172.16.0.0 prefixlen 12 := "Network2", + network 192.168.0.0 prefixlen 16 := "Network3", + network 2402:9400:1000:0:: prefixlen 64 := "Network4", + host 192.168.20.1 := "Host1", + host 172.16.1.1 := "Host2", + host 172.16.1.1/32 := "Host3", + host 2001:0db8:85a3:0000:0000:8a2e:0370:7334 := "Host4", + host 2001:0db8:85a3:0000:0000:8a2e:0370:7334/128 := "Host5" + +- name: Show the data format expected for records_content - address 2 + copy: + dest: /path/to/addresses.txt + content: | + 10.0.0.0/8 := "Network1", + 172.16.0.0/12 := "Network2", + 192.168.0.0/16 := "Network3", + 2402:9400:1000:0::/64 := "Network4", + 192.168.20.1 := "Host1", + 172.16.1.1 := "Host2", + 172.16.1.1/32 := "Host3", + 2001:0db8:85a3:0000:0000:8a2e:0370:7334 := "Host4", + 2001:0db8:85a3:0000:0000:8a2e:0370:7334/128 := "Host5" + +- name: Show the data format expected for records_content - string + copy: + dest: /path/to/strings.txt + content: | + a := alpha, + b := bravo, + c := charlie, + x := x-ray, + y := yankee, + z := zulu, + +- name: Show the data format expected for records_content - integer + copy: + dest: /path/to/integers.txt + content: | + 1 := bar, + 2 := baz, + 3, + 4, +''' + +RETURN = r''' +# only common fields returned +''' + +import hashlib +import os +import re + +from ansible.module_utils._text import to_text +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import env_fallback +from io import StringIO + +try: + from library.module_utils.network.f5.bigip import HAS_F5SDK + from library.module_utils.network.f5.bigip import F5Client + from library.module_utils.network.f5.common import F5ModuleError + from library.module_utils.network.f5.common import AnsibleF5Parameters + from library.module_utils.network.f5.common import cleanup_tokens + from library.module_utils.network.f5.common import compare_dictionary + from library.module_utils.network.f5.common import f5_argument_spec + try: + from library.module_utils.network.f5.common import iControlUnexpectedHTTPError + except ImportError: + HAS_F5SDK = False +except ImportError: + from ansible.module_utils.network.f5.bigip import HAS_F5SDK + from ansible.module_utils.network.f5.bigip import F5Client + from ansible.module_utils.network.f5.common import F5ModuleError + from ansible.module_utils.network.f5.common import AnsibleF5Parameters + from ansible.module_utils.network.f5.common import cleanup_tokens + from ansible.module_utils.network.f5.common import compare_dictionary + from ansible.module_utils.network.f5.common import f5_argument_spec + try: + from ansible.module_utils.network.f5.common import iControlUnexpectedHTTPError + except ImportError: + HAS_F5SDK = False + +try: + import netaddr + HAS_NETADDR = True +except ImportError: + HAS_NETADDR = False + + +LINE_LIMIT = 65000 +SIZE_LIMIT_BYTES = 4000000 + + +def zero_length(content): + content.seek(0, os.SEEK_END) + length = content.tell() + content.seek(0) + if length == 0: + return True + return False + + +def size_exceeded(content): + records = content + records.seek(0, os.SEEK_END) + size = records.tell() + records.seek(0) + if size > SIZE_LIMIT_BYTES: + return True + return False + + +def lines_exceeded(content): + result = False + for i, line in enumerate(content): + if i > LINE_LIMIT: + result = True + content.seek(0) + return result + + +class RecordsEncoder(object): + def __init__(self, record_type=None, separator=None): + self._record_type = record_type + self._separator = separator + self._network_pattern = re.compile(r'^network\s+(?P[^ ]+)\s+prefixlen\s+(?P\d+)\s+.*') + self._host_pattern = re.compile(r'^host\s+(?P[^ ]+)\s+.*') + + def encode(self, record): + if isinstance(record, dict): + return self.encode_dict(record) + else: + return self.encode_string(record) + + def encode_dict(self, record): + if self._record_type == 'ip': + return self.encode_address_from_dict(record) + elif self._record_type == 'integer': + return self.encode_integer_from_dict(record) + else: + return self.encode_string_from_dict(record) + + def encode_address_from_dict(self, record): + try: + key = netaddr.IPNetwork(record['key']) + except netaddr.core.AddrFormatError: + raise F5ModuleError( + "When specifying an 'address' type, the value to the left of the separator must be an IP." + ) + if key and 'value' in record: + if key.prefixlen in [32, 128]: + return self.encode_host(key.ip, record['value']) + else: + return self.encode_network(key.network, key.prefixlen, record['value']) + elif key: + if key.prefixlen in [32, 128]: + return self.encode_host(key.ip, key.ip) + else: + return self.encode_network(key.network, key.prefixlen, key.network) + + def encode_integer_from_dict(self, record): + try: + int(record['key']) + except ValueError: + raise F5ModuleError( + "When specifying an 'integer' type, the value to the left of the separator must be a number." + ) + if 'key' in record and 'value' in record: + return '{0} {1} {2}'.format(record['key'], self._separator, record['value']) + elif 'key' in record: + return str(record['key']) + + def encode_string_from_dict(self, record): + if 'key' in record and 'value' in record: + return '{0} {1} {2}'.format(record['key'], self._separator, record['value']) + elif 'key' in record: + return '{0} {1} ""'.format(record['key'], self._separator) + + def encode_string(self, record): + record = record.strip().strip(',') + if self._record_type == 'ip': + return self.encode_address_from_string(record) + elif self._record_type == 'integer': + return self.encode_integer_from_string(record) + else: + return self.encode_string_from_string(record) + + def encode_address_from_string(self, record): + if self._network_pattern.match(record): + # network 192.168.0.0 prefixlen 16 := "Network3", + # network 2402:9400:1000:0:: prefixlen 64 := "Network4", + return record + elif self._host_pattern.match(record): + # host 172.16.1.1/32 := "Host3" + # host 2001:0db8:85a3:0000:0000:8a2e:0370:7334 := "Host4" + return record + else: + # 192.168.0.0/16 := "Network3", + # 2402:9400:1000:0::/64 := "Network4", + try: + parts = record.split(self._separator) + if len(parts) == 2: + key = netaddr.IPNetwork(parts[0]) + if key.prefixlen in [32, 128]: + return self.encode_host(key.ip, parts[1]) + else: + return self.encode_network(key.network, key.prefixlen, parts[1]) + elif len(parts) == 1 and parts[0] != '': + key = netaddr.IPNetwork(parts[0]) + if key.prefixlen in [32, 128]: + return self.encode_host(key.ip, key.ip) + else: + return self.encode_network(key.network, key.prefixlen, key.network) + except netaddr.core.AddrFormatError: + raise F5ModuleError( + "When specifying an 'address' type, the value to the left of the separator must be an IP." + ) + + def encode_host(self, key, value): + return 'host {0} {1} {2}'.format(str(key), self._separator, str(value)) + + def encode_network(self, key, prefixlen, value): + return 'network {0} prefixlen {1} {2} {3}'.format( + str(key), str(prefixlen), self._separator, str(value) + ) + + def encode_integer_from_string(self, record): + parts = record.split(self._separator) + if len(parts) == 1 and parts[0] == '': + return None + try: + int(parts[0]) + except ValueError: + raise F5ModuleError( + "When specifying an 'integer' type, the value to the left of the separator must be a number." + ) + if len(parts) == 2: + return '{0} {1} {2}'.format(parts[0], self._separator, parts[1]) + elif len(parts) == 1: + return str(parts[0]) + + def encode_string_from_string(self, record): + parts = record.split(self._separator) + if len(parts) == 2: + return '{0} {1} {2}'.format(parts[0], self._separator, parts[1]) + elif len(parts) == 1 and parts[0] != '': + return '{0} {1} ""'.format(parts[0], self._separator) + + +class RecordsDecoder(object): + def __init__(self, record_type=None, separator=None): + self._record_type = record_type + self._separator = separator + self._network_pattern = re.compile(r'^network\s+(?P[^ ]+)\s+prefixlen\s+(?P\d+)\s+.*') + self._host_pattern = re.compile(r'^host\s+(?P[^ ]+)\s+.*') + + def decode(self, record): + record = record.strip().strip(',') + if self._record_type == 'ip': + return self.decode_address_from_string(record) + else: + return self.decode_from_string(record) + + def decode_address_from_string(self, record): + try: + matches = self._network_pattern.match(record) + if matches: + # network 192.168.0.0 prefixlen 16 := "Network3", + # network 2402:9400:1000:0:: prefixlen 64 := "Network4", + key = "{0}/{1}".format(matches.group('addr'), matches.group('prefix')) + addr = netaddr.IPNetwork(key) + value = record.split(self._separator)[1].strip().strip('"') + result = dict(name=str(addr), data=value) + return result + matches = self._host_pattern.match(record) + if matches: + # host 172.16.1.1/32 := "Host3" + # host 2001:0db8:85a3:0000:0000:8a2e:0370:7334 := "Host4" + key = matches.group('addr') + addr = netaddr.IPNetwork(key) + value = record.split(self._separator)[1].strip().strip('"') + result = dict(name=str(addr), data=value) + return result + except netaddr.core.AddrFormatError: + raise F5ModuleError( + 'The value "{0}" is not an address'.format(record) + ) + + def decode_from_string(self, record): + parts = record.split(self._separator) + if len(parts) == 2: + return dict(name=parts[0].strip(), data=parts[1].strip('"').strip()) + else: + return dict(name=parts[0].strip(), data="") + + +class Parameters(AnsibleF5Parameters): + api_map = { + 'externalFileName': 'external_file_name' + } + + api_attributes = [ + 'records', 'type' + ] + + returnables = [] + + updatables = [ + 'records', 'checksum' + ] + + @property + def type(self): + if self._values['type'] in ['address', 'addr', 'ip']: + return 'ip' + elif self._values['type'] in ['integer', 'int']: + return 'integer' + elif self._values['type'] in ['string']: + return 'string' + + @property + def records_src(self): + try: + self._values['records_src'].seek(0) + return self._values['records_src'] + except AttributeError: + pass + if self._values['records_src']: + records = open(self._values['records_src']) + else: + records = self._values['records'] + + # There is a 98% chance that the user will supply a data group that is < 1MB. + # 99.917% chance it is less than 10 MB. This is well within the range of typical + # memory available on a system. + # + # If this changes, this may need to be changed to use temporary files instead. + self._values['records_src'] = StringIO() + + self._write_records_to_file(records) + return self._values['records_src'] + + def _write_records_to_file(self, records): + bucket_size = 1000000 + bucket = [] + encoder = RecordsEncoder(record_type=self.type, separator=self.separator) + for record in records: + result = encoder.encode(record) + if result: + bucket.append(to_text(result + ",\n")) + if len(bucket) == bucket_size: + self._values['records_src'].writelines(bucket) + bucket = [] + self._values['records_src'].writelines(bucket) + self._values['records_src'].seek(0) + + +class ApiParameters(Parameters): + @property + def checksum(self): + if self._values['checksum'] is None: + return None + result = self._values['checksum'].split(':')[2] + return result + + @property + def records(self): + if self._values['records'] is None: + return None + return self._values['records'] + + @property + def records_list(self): + return self.records + + +class ModuleParameters(Parameters): + @property + def checksum(self): + if self._values['checksum']: + return self._values['checksum'] + result = hashlib.sha1() + records = self.records_src + while True: + data = records.read(4096) + if not data: + break + result.update(data) + result = result.hexdigest() + self._values['checksum'] = result + return result + + @property + def external_file_name(self): + if self._values['external_file_name'] is None: + name = self.name + else: + name = self._values['external_file_name'] + if re.search(r'[^a-z0-9-_.]', name): + raise F5ModuleError( + "'external_file_name' may only contain letters, numbers, underscores, dashes, or a period." + ) + return name + + @property + def records(self): + results = [] + decoder = RecordsDecoder(record_type=self.type, separator=self.separator) + for record in self.records_src: + result = decoder.decode(record) + if result: + results.append(result) + return results + + @property + def records_list(self): + if self._values['records'] is None: + return None + return self.records + + +class Changes(Parameters): + def to_return(self): + result = {} + try: + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + except Exception: + pass + return result + + +class UsableChanges(Changes): + pass + + +class ReportableChanges(Changes): + pass + + +class Difference(object): + def __init__(self, want, have=None): + self.want = want + self.have = have + + def compare(self, param): + try: + result = getattr(self, param) + return result + except AttributeError: + return self.__default(param) + + def __default(self, param): + attr1 = getattr(self.want, param) + try: + attr2 = getattr(self.have, param) + if attr1 != attr2: + return attr1 + except AttributeError: + return attr1 + + @property + def records(self): + # External data groups are compared by their checksum, not their records. This + # is because the BIG-IP does not store the actual records in the API. It instead + # stores the checksum of the file. External DGs have the possibility of being huge + # and we would never want to do a comparison of such huge files. + # + # Therefore, comparison is no-op if the DG being worked with is an external DG. + if self.want.internal is False: + return None + if self.have.records is None and self.want.records == []: + return None + if self.have.records is None: + return self.want.records + result = compare_dictionary(self.want.records, self.have.records) + return result + + @property + def type(self): + return None + + @property + def checksum(self): + if self.want.internal: + return None + if self.want.checksum != self.have.checksum: + return True + + +class BaseManager(object): + def __init__(self, *args, **kwargs): + self.module = kwargs.get('module', None) + self.client = kwargs.get('client', None) + self.want = ModuleParameters(params=self.module.params) + self.have = ApiParameters() + self.changes = UsableChanges() + + def should_update(self): + result = self._update_changed_options() + if result: + return True + return False + + 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)) + + reportable = ReportableChanges(params=self.changes.to_return()) + changes = reportable.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + self._announce_deprecations(result) + return result + + def _announce_deprecations(self, result): + warnings = result.pop('__warnings', []) + for warning in warnings: + self.client.module.deprecate( + msg=warning['msg'], + version=warning['version'] + ) + + def _set_changed_options(self): + changed = {} + for key in ApiParameters.returnables: + if getattr(self.want, key) is not None: + changed[key] = getattr(self.want, key) + if changed: + self.changes = UsableChanges(params=changed) + + def _update_changed_options(self): + diff = Difference(self.want, self.have) + updatables = ApiParameters.updatables + changed = dict() + for k in updatables: + change = diff.compare(k) + if change is None: + continue + else: + if isinstance(change, dict): + changed.update(change) + else: + changed[k] = change + if changed: + self.changes = UsableChanges(params=changed) + return True + return False + + def present(self): + if self.exists(): + return self.update() + else: + return self.create() + + def absent(self): + if self.exists(): + return self.remove() + return False + + def remove(self): + if self.module.check_mode: + return True + self.remove_from_device() + if self.exists(): + raise F5ModuleError("Failed to delete the resource.") + return True + + +class InternalManager(BaseManager): + def create(self): + self._set_changed_options() + if size_exceeded(self.want.records_src) or lines_exceeded(self.want.records_src): + raise F5ModuleError( + "The size of the provided data (or file) is too large for an internal data group." + ) + if self.module.check_mode: + return True + self.create_on_device() + return True + + def update(self): + self.have = self.read_current_from_device() + if not self.should_update(): + return False + if self.module.check_mode: + return True + self.update_on_device() + return True + + def exists(self): + result = self.client.api.tm.ltm.data_group.internals.internal.exists( + name=self.want.name, + partition=self.want.partition + ) + return result + + def create_on_device(self): + params = self.want.api_params() + self.client.api.tm.ltm.data_group.internals.internal.create( + name=self.want.name, + partition=self.want.partition, + **params + ) + + def update_on_device(self): + params = self.changes.api_params() + resource = self.client.api.tm.ltm.data_group.internals.internal.load( + name=self.want.name, + partition=self.want.partition + ) + resource.modify(**params) + + def remove_from_device(self): + resource = self.client.api.tm.ltm.data_group.internals.internal.load( + name=self.want.name, + partition=self.want.partition + ) + if resource: + resource.delete() + + def read_current_from_device(self): + resource = self.client.api.tm.ltm.data_group.internals.internal.load( + name=self.want.name, + partition=self.want.partition + ) + result = resource.attrs + return ApiParameters(params=result) + + +class ExternalManager(BaseManager): + def absent(self): + result = False + if self.exists(): + result = self.remove() + if self.external_file_exists() and self.want.delete_data_group_file: + result = self.remove_data_group_file_from_device() + return result + + def create(self): + if zero_length(self.want.records_src): + raise F5ModuleError( + "An external data group cannot be empty." + ) + self._set_changed_options() + if self.module.check_mode: + return True + self.create_on_device() + return True + + def update(self): + self.have = self.read_current_from_device() + if not self.should_update(): + return False + if zero_length(self.want.records_src): + raise F5ModuleError( + "An external data group cannot be empty." + ) + if self.module.check_mode: + return True + self.update_on_device() + return True + + def exists(self): + result = self.client.api.tm.ltm.data_group.externals.external.exists( + name=self.want.name, + partition=self.want.partition + ) + return result + + def external_file_exists(self): + result = self.client.api.tm.sys.file.data_groups.data_group.exists( + name=self.want.external_file_name, + partition=self.want.partition + ) + return result + + def _upload_to_file(self, name, type, remote_path, update=False): + self.client.api.shared.file_transfer.uploads.upload_stringio(self.want.records_src, name) + resource = self.client.api.tm.sys.file.data_groups + if update: + resource = resource.data_group.load( + name=name, + partition=self.want.partition + ) + resource.modify( + sourcePath='file:{0}'.format(remote_path) + ) + resource.refresh() + result = resource + else: + result = resource.data_group.create( + name=name, + type=type, + sourcePath='file:{0}'.format(remote_path) + ) + return result.name + + def create_on_device(self): + name = self.want.external_file_name + remote_path = '/var/config/rest/downloads/{0}'.format(name) + external_file = self._upload_to_file(name, self.want.type, remote_path, update=False) + self.client.api.tm.ltm.data_group.externals.external.create( + name=self.want.name, + partition=self.want.partition, + externalFileName=external_file + ) + self.client.api.tm.util.unix_rm.exec_cmd('run', utilCmdArgs=remote_path) + + def update_on_device(self): + name = self.want.external_file_name + remote_path = '/var/config/rest/downloads/{0}'.format(name) + external_file = self._upload_to_file(name, self.have.type, remote_path, update=True) + resource = self.client.api.tm.ltm.data_group.externals.external.load( + name=self.want.name, + partition=self.want.partition + ) + resource.modify( + externalFileName=external_file + ) + + def remove_from_device(self): + resource = self.client.api.tm.ltm.data_group.externals.external.load( + name=self.want.name, + partition=self.want.partition + ) + if resource: + resource.delete() + + # Remove the remote data group file if asked to + if self.want.delete_data_group_file: + self.remove_data_group_file_from_device() + + def remove_data_group_file_from_device(self): + resource = self.client.api.tm.sys.file.data_groups.data_group.load( + name=self.want.external_file_name, + partition=self.want.partition + ) + if resource: + resource.delete() + return True + return False + + def read_current_from_device(self): + """Reads the current configuration from the device + + For an external data group, we are interested in two things from the + current configuration + + * ``checksum`` + * ``type`` + + The ``checksum`` will allow us to compare the data group value we have + with the data group value being provided. + + The ``type`` will allow us to do validation on the data group value being + provided (if any). + + Returns: + ExternalApiParameters: Attributes of the remote resource. + """ + resource = self.client.api.tm.ltm.data_group.externals.external.load( + name=self.want.name, + partition=self.want.partition + ) + external_file = os.path.basename(resource.externalFileName) + external_file_partition = os.path.dirname(resource.externalFileName).strip('/') + resource = self.client.api.tm.sys.file.data_groups.data_group.load( + name=external_file, + partition=external_file_partition + ) + result = resource.attrs + return ApiParameters(params=result) + + +class ModuleManager(object): + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + self.module = kwargs.get('module') + self.client = kwargs.get('client', None) + + def exec_module(self): + if self.module.params['internal']: + manager = self.get_manager('internal') + else: + manager = self.get_manager('external') + return manager.exec_module() + + def get_manager(self, type): + if type == 'internal': + return InternalManager(**self.kwargs) + elif type == 'external': + return ExternalManager(**self.kwargs) + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + argument_spec = dict( + name=dict(required=True), + type=dict( + choices=['address', 'addr', 'ip', 'string', 'str', 'integer', 'int'], + default='string' + ), + delete_data_group_file=dict(type='bool'), + internal=dict(type='bool', default='no'), + records=dict( + type='list', + suboptions=dict( + key=dict(required=True), + value=dict(type='raw') + ) + ), + records_src=dict(type='path'), + external_file_name=dict(), + separator=dict(default=':='), + state=dict(choices=['absent', 'present'], default='present'), + partition=dict( + default='Common', + fallback=(env_fallback, ['F5_PARTITION']) + ) + ) + self.argument_spec = {} + self.argument_spec.update(f5_argument_spec) + self.argument_spec.update(argument_spec) + self.mutually_exclusive = [ + ['records', 'records_content', 'external_file_name'] + ] + + +def main(): + spec = ArgumentSpec() + + module = AnsibleModule( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode + ) + if not HAS_F5SDK: + module.fail_json(msg="The python f5-sdk module is required") + if not HAS_NETADDR: + module.fail_json(msg="The python netaddr module is required") + + try: + client = F5Client(**module.params) + mm = ModuleManager(module=module, client=client) + results = mm.exec_module() + cleanup_tokens(client) + module.exit_json(**results) + except F5ModuleError as ex: + cleanup_tokens(client) + module.fail_json(msg=str(ex)) + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/modules/network/f5/bigip_wait.py b/lib/ansible/modules/network/f5/bigip_wait.py index fb1e731346f..78ca05a504e 100644 --- a/lib/ansible/modules/network/f5/bigip_wait.py +++ b/lib/ansible/modules/network/f5/bigip_wait.py @@ -20,7 +20,7 @@ description: to accept configuration. - This module can take into account situations where the device is in the middle of rebooting due to a configuration change. -version_added: "2.5" +version_added: 2.5 options: timeout: description: @@ -80,30 +80,21 @@ import time from ansible.module_utils.basic import AnsibleModule -HAS_DEVEL_IMPORTS = False - try: - # Sideband repository used for dev from library.module_utils.network.f5.bigip import HAS_F5SDK from library.module_utils.network.f5.bigip import F5Client from library.module_utils.network.f5.common import F5ModuleError from library.module_utils.network.f5.common import AnsibleF5Parameters - from library.module_utils.network.f5.common import cleanup_tokens - from library.module_utils.network.f5.common import fqdn_name from library.module_utils.network.f5.common import f5_argument_spec try: from library.module_utils.network.f5.common import iControlUnexpectedHTTPError except ImportError: HAS_F5SDK = False - HAS_DEVEL_IMPORTS = True except ImportError: - # Upstream Ansible from ansible.module_utils.network.f5.bigip import HAS_F5SDK from ansible.module_utils.network.f5.bigip import F5Client from ansible.module_utils.network.f5.common import F5ModuleError from ansible.module_utils.network.f5.common import AnsibleF5Parameters - from ansible.module_utils.network.f5.common import cleanup_tokens - from ansible.module_utils.network.f5.common import fqdn_name from ansible.module_utils.network.f5.common import f5_argument_spec try: from ansible.module_utils.network.f5.common import iControlUnexpectedHTTPError @@ -186,6 +177,9 @@ class ModuleManager(object): version=warning['version'] ) + def _get_client_connection(self): + return F5Client(**self.module.params) + def execute(self): signal.signal( signal.SIGALRM, @@ -204,7 +198,7 @@ class ModuleManager(object): try: # The first test verifies that the REST API is available; this is done # by repeatedly trying to login to it. - self.client = F5Client(**self.module.params) + self.client = self._get_client_connection() if not self.client: continue diff --git a/test/units/modules/network/f5/fixtures/data-group-address.txt b/test/units/modules/network/f5/fixtures/data-group-address.txt new file mode 100644 index 00000000000..d2f5a168770 --- /dev/null +++ b/test/units/modules/network/f5/fixtures/data-group-address.txt @@ -0,0 +1,5 @@ +network 10.0.0.0 prefixlen 8 := "Network1", +network 172.16.0.0 prefixlen 12 := "Network2", +network 192.168.0.0 prefixlen 16 := "Network3", +host 192.168.20.1 := "Host1", +host 172.16.1.1 := "Host2", diff --git a/test/units/modules/network/f5/fixtures/data-group-integer.txt b/test/units/modules/network/f5/fixtures/data-group-integer.txt new file mode 100644 index 00000000000..711ac1987ba --- /dev/null +++ b/test/units/modules/network/f5/fixtures/data-group-integer.txt @@ -0,0 +1,6 @@ +1 := alpha +2 := bravo +3 := charlie +4 := x-ray +5 := yankee +6 := zulu diff --git a/test/units/modules/network/f5/fixtures/data-group-string.txt b/test/units/modules/network/f5/fixtures/data-group-string.txt new file mode 100644 index 00000000000..c30fca282cf --- /dev/null +++ b/test/units/modules/network/f5/fixtures/data-group-string.txt @@ -0,0 +1,6 @@ +a := alpha +b := bravo +c := charlie +x := x-ray +y := yankee +z := zulu diff --git a/test/units/modules/network/f5/test_bigip_data_group.py b/test/units/modules/network/f5/test_bigip_data_group.py new file mode 100644 index 00000000000..83aacfd6cbe --- /dev/null +++ b/test/units/modules/network/f5/test_bigip_data_group.py @@ -0,0 +1,463 @@ +# -*- 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 pytest +import sys + +from nose.plugins.skip import SkipTest +if sys.version_info < (2, 7): + raise SkipTest("F5 Ansible modules require Python >= 2.7") + +from ansible.compat.tests import unittest +from ansible.compat.tests.mock import Mock +from ansible.compat.tests.mock import patch +from ansible.module_utils.basic import AnsibleModule + +try: + from library.modules.bigip_data_group import ModuleParameters + from library.modules.bigip_data_group import ModuleManager + from library.modules.bigip_data_group import ExternalManager + from library.modules.bigip_data_group import InternalManager + from library.modules.bigip_data_group import ArgumentSpec + from library.module_utils.network.f5.common import F5ModuleError + from library.module_utils.network.f5.common import iControlUnexpectedHTTPError + from test.unit.modules.utils import set_module_args +except ImportError: + try: + from ansible.modules.network.f5.bigip_data_group import ModuleParameters + from ansible.modules.network.f5.bigip_data_group import ModuleManager + from ansible.modules.network.f5.bigip_data_group import ExternalManager + from ansible.modules.network.f5.bigip_data_group import InternalManager + from ansible.modules.network.f5.bigip_data_group import ArgumentSpec + from ansible.module_utils.network.f5.common import F5ModuleError + from ansible.module_utils.network.f5.common import iControlUnexpectedHTTPError + from units.modules.utils import set_module_args + 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 load_fixture(name): + path = os.path.join(fixture_path, name) + + if path in fixture_data: + return fixture_data[path] + + with open(path) as f: + data = f.read() + + try: + data = json.loads(data) + except Exception: + pass + + fixture_data[path] = data + return data + + +class TestParameters(unittest.TestCase): + def test_module_parameters(self): + args = dict( + name='foo', + type='address', + delete_data_group_file=False, + internal=False, + records=[ + dict( + key='10.10.10.10/32', + value='bar' + ) + ], + separator=':=', + state='present', + partition='Common' + ) + + p = ModuleParameters(params=args) + assert p.name == 'foo' + assert p.type == 'ip' + assert p.delete_data_group_file is False + assert len(p.records) == 1 + assert 'data' in p.records[0] + assert 'name' in p.records[0] + assert p.records[0]['data'] == 'bar' + assert p.records[0]['name'] == '10.10.10.10/32' + assert p.separator == ':=' + assert p.state == 'present' + assert p.partition == 'Common' + + +@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_create_external_datagroup_type_string(self, *args): + set_module_args(dict( + name='foo', + delete_data_group_file=False, + internal=False, + records_src="{0}/data-group-string.txt".format(fixture_path), + separator=':=', + state='present', + partition='Common', + server='localhost', + password='password', + user='admin' + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + # Override methods in the specific type of manager + mm1 = ExternalManager(module=module, params=module.params) + mm1.exists = Mock(side_effect=[False, True]) + mm1.create_on_device = Mock(return_value=True) + + # Override methods to force specific logic in the module to happen + mm0 = ModuleManager(module=module) + mm0.get_manager = Mock(return_value=mm1) + + results = mm0.exec_module() + + assert results['changed'] is True + + def test_create_external_incorrect_address_data(self, *args): + set_module_args(dict( + name='foo', + delete_data_group_file=False, + internal=False, + type='address', + records_src="{0}/data-group-string.txt".format(fixture_path), + separator=':=', + state='present', + partition='Common', + server='localhost', + password='password', + user='admin' + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + # Override methods in the specific type of manager + mm1 = ExternalManager(module=module, params=module.params) + mm1.exists = Mock(side_effect=[False, True]) + mm1.create_on_device = Mock(return_value=True) + + # Override methods to force specific logic in the module to happen + mm0 = ModuleManager(module=module) + mm0.get_manager = Mock(return_value=mm1) + + with pytest.raises(F5ModuleError) as ex: + mm0.exec_module() + + assert "When specifying an 'address' type, the value to the left of the separator must be an IP." == str(ex.value) + + def test_create_external_incorrect_integer_data(self, *args): + set_module_args(dict( + name='foo', + delete_data_group_file=False, + internal=False, + type='integer', + records_src="{0}/data-group-string.txt".format(fixture_path), + separator=':=', + state='present', + partition='Common', + server='localhost', + password='password', + user='admin' + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + # Override methods in the specific type of manager + mm1 = ExternalManager(module=module, params=module.params) + mm1.exists = Mock(side_effect=[False, True]) + mm1.create_on_device = Mock(return_value=True) + + # Override methods to force specific logic in the module to happen + mm0 = ModuleManager(module=module) + mm0.get_manager = Mock(return_value=mm1) + + with pytest.raises(F5ModuleError) as ex: + mm0.exec_module() + + assert "When specifying an 'integer' type, the value to the left of the separator must be a number." == str(ex.value) + + def test_remove_data_group_keep_file(self, *args): + set_module_args(dict( + name='foo', + delete_data_group_file=False, + internal=False, + state='absent', + partition='Common', + server='localhost', + password='password', + user='admin' + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + # Override methods in the specific type of manager + mm1 = ExternalManager(module=module, params=module.params) + mm1.exists = Mock(side_effect=[True, False]) + mm1.remove_from_device = Mock(return_value=True) + mm1.external_file_exists = Mock(return_value=True) + + # Override methods to force specific logic in the module to happen + mm0 = ModuleManager(module=module) + mm0.get_manager = Mock(return_value=mm1) + + results = mm0.exec_module() + + assert results['changed'] is True + + def test_remove_data_group_remove_file(self, *args): + set_module_args(dict( + name='foo', + delete_data_group_file=True, + internal=False, + state='absent', + partition='Common', + server='localhost', + password='password', + user='admin' + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + # Override methods in the specific type of manager + mm1 = ExternalManager(module=module, params=module.params) + mm1.exists = Mock(side_effect=[True, False]) + mm1.remove_from_device = Mock(return_value=True) + mm1.external_file_exists = Mock(return_value=True) + mm1.remove_data_group_file_from_device = Mock(return_value=True) + + # Override methods to force specific logic in the module to happen + mm0 = ModuleManager(module=module) + mm0.get_manager = Mock(return_value=mm1) + + results = mm0.exec_module() + + assert results['changed'] is True + + def test_create_internal_datagroup_type_string(self, *args): + set_module_args(dict( + name='foo', + delete_data_group_file=False, + internal=True, + records_src="{0}/data-group-string.txt".format(fixture_path), + separator=':=', + state='present', + partition='Common', + server='localhost', + password='password', + user='admin' + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + # Override methods in the specific type of manager + mm1 = InternalManager(module=module, params=module.params) + mm1.exists = Mock(side_effect=[False, True]) + mm1.create_on_device = Mock(return_value=True) + + # Override methods to force specific logic in the module to happen + mm0 = ModuleManager(module=module) + mm0.get_manager = Mock(return_value=mm1) + + results = mm0.exec_module() + + assert results['changed'] is True + + def test_create_internal_incorrect_integer_data(self, *args): + set_module_args(dict( + name='foo', + delete_data_group_file=False, + internal=True, + type='integer', + records_src="{0}/data-group-string.txt".format(fixture_path), + separator=':=', + state='present', + partition='Common', + server='localhost', + password='password', + user='admin' + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + # Override methods in the specific type of manager + mm1 = InternalManager(module=module, params=module.params) + mm1.exists = Mock(side_effect=[False, True]) + mm1.create_on_device = Mock(return_value=True) + + # Override methods to force specific logic in the module to happen + mm0 = ModuleManager(module=module) + mm0.get_manager = Mock(return_value=mm1) + + with pytest.raises(F5ModuleError) as ex: + mm0.exec_module() + + assert "When specifying an 'integer' type, the value to the left of the separator must be a number." == str(ex.value) + + def test_create_internal_datagroup_type_integer(self, *args): + set_module_args(dict( + name='foo', + delete_data_group_file=False, + internal=True, + type='integer', + records_src="{0}/data-group-integer.txt".format(fixture_path), + separator=':=', + state='present', + partition='Common', + server='localhost', + password='password', + user='admin' + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + # Override methods in the specific type of manager + mm1 = InternalManager(module=module, params=module.params) + mm1.exists = Mock(side_effect=[False, True]) + mm1.create_on_device = Mock(return_value=True) + + # Override methods to force specific logic in the module to happen + mm0 = ModuleManager(module=module) + mm0.get_manager = Mock(return_value=mm1) + + results = mm0.exec_module() + + assert results['changed'] is True + + def test_create_internal_datagroup_type_address(self, *args): + set_module_args(dict( + name='foo', + delete_data_group_file=False, + internal=True, + type='address', + records_src="{0}/data-group-address.txt".format(fixture_path), + separator=':=', + state='present', + partition='Common', + server='localhost', + password='password', + user='admin' + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + # Override methods in the specific type of manager + mm1 = InternalManager(module=module, params=module.params) + mm1.exists = Mock(side_effect=[False, True]) + mm1.create_on_device = Mock(return_value=True) + + # Override methods to force specific logic in the module to happen + mm0 = ModuleManager(module=module) + mm0.get_manager = Mock(return_value=mm1) + + results = mm0.exec_module() + + assert results['changed'] is True + + def test_create_internal_datagroup_type_address_list(self, *args): + set_module_args(dict( + name='foo', + delete_data_group_file=False, + internal=True, + type='address', + records=[ + dict( + key='10.0.0.0/8', + value='Network1' + ), + dict( + key='172.16.0.0/12', + value='Network2' + ), + dict( + key='192.168.20.1/16', + value='Network3' + ), + dict( + key='192.168.20.1', + value='Host1' + ), + dict( + key='172.16.1.1', + value='Host2' + ) + ], + separator=':=', + state='present', + partition='Common', + server='localhost', + password='password', + user='admin' + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + # Override methods in the specific type of manager + mm1 = InternalManager(module=module, params=module.params) + mm1.exists = Mock(side_effect=[False, True]) + mm1.create_on_device = Mock(return_value=True) + + # Override methods to force specific logic in the module to happen + mm0 = ModuleManager(module=module) + mm0.get_manager = Mock(return_value=mm1) + + results = mm0.exec_module() + + assert results['changed'] is True diff --git a/test/units/modules/network/f5/test_bigip_wait.py b/test/units/modules/network/f5/test_bigip_wait.py index 277c3e2aca9..cceb108f02f 100644 --- a/test/units/modules/network/f5/test_bigip_wait.py +++ b/test/units/modules/network/f5/test_bigip_wait.py @@ -21,9 +21,9 @@ from ansible.compat.tests.mock import patch from ansible.module_utils.basic import AnsibleModule try: - from library.bigip_wait import Parameters - from library.bigip_wait import ModuleManager - from library.bigip_wait import ArgumentSpec + from library.modules.bigip_wait import Parameters + from library.modules.bigip_wait import ModuleManager + from library.modules.bigip_wait import ArgumentSpec from library.module_utils.network.f5.common import F5ModuleError from library.module_utils.network.f5.common import iControlUnexpectedHTTPError from test.unit.modules.utils import set_module_args @@ -116,6 +116,7 @@ class TestManager(unittest.TestCase): mm._connect_to_device = Mock(return_value=True) mm._device_is_rebooting = Mock(return_value=False) mm._is_mprov_running_on_device = Mock(return_value=False) + mm._get_client_connection = Mock(return_value=True) results = mm.exec_module()