From 00a6b19e58097905e4c33ed30ff40b91217c323b Mon Sep 17 00:00:00 2001 From: Tim Rupp Date: Fri, 11 May 2018 11:45:42 -0700 Subject: [PATCH] Fixes and additions for f5 modules (#39986) Small fixes in the f5 module utils. I believe the action plugins now work consistently across types of connections --- lib/ansible/module_utils/network/f5/bigip.py | 10 +- lib/ansible/module_utils/network/f5/bigiq.py | 50 ++++++---- lib/ansible/module_utils/network/f5/common.py | 24 ++++- .../module_utils/network/f5/icontrol.py | 80 +++------------ lib/ansible/plugins/action/bigip.py | 73 ++------------ lib/ansible/plugins/action/bigiq.py | 97 +++++++++++++++++++ 6 files changed, 172 insertions(+), 162 deletions(-) create mode 100644 lib/ansible/plugins/action/bigiq.py diff --git a/lib/ansible/module_utils/network/f5/bigip.py b/lib/ansible/module_utils/network/f5/bigip.py index 8fde9fc7a75..2065d412995 100644 --- a/lib/ansible/module_utils/network/f5/bigip.py +++ b/lib/ansible/module_utils/network/f5/bigip.py @@ -80,15 +80,15 @@ class F5RestClient(F5BaseClient): payload = { 'username': self.provider['user'], 'password': self.provider['password'], - 'loginProviderName': self.provider['auth_provider'] + 'loginProviderName': self.provider['auth_provider'] or 'tmos' } 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 + if response.status not in [200]: + raise F5ModuleError('Status code: {0}. Unexpected Error: {1} for uri: {2}\nText: {3}'.format( + response.status, response.reason, response.url, response._content )) session.headers['X-F5-Auth-Token'] = response.json()['token']['token'] @@ -98,7 +98,7 @@ class F5RestClient(F5BaseClient): exc = ex time.sleep(1) error = 'Unable to connect to {0} on port {1}.'.format( - self.params['server'], self.params['server_port'] + self.provider['server'], self.provider['server_port'] ) if exc is not None: error += ' The reported error was "{0}".'.format(str(exc)) diff --git a/lib/ansible/module_utils/network/f5/bigiq.py b/lib/ansible/module_utils/network/f5/bigiq.py index 17f4ceeb8d3..02254886db4 100644 --- a/lib/ansible/module_utils/network/f5/bigiq.py +++ b/lib/ansible/module_utils/network/f5/bigiq.py @@ -61,35 +61,43 @@ class F5Client(F5BaseClient): class F5RestClient(F5BaseClient): + def __init__(self, *args, **kwargs): + super(F5RestClient, self).__init__(*args, **kwargs) + self.provider = self.merge_provider_params() + @property def api(self): - ex = None + exc = 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) + url = "https://{0}:{1}/mgmt/shared/authn/login".format( + self.provider['server'], self.provider['server_port'] ) - self._client = result + payload = { + 'username': self.provider['user'], + 'password': self.provider['password'], + 'loginProviderName': self.provider['auth_provider'] or 'local' + } + session = iControlRestSession() + session.verify = self.provider['validate_certs'] + response = session.post(url, json=payload) + + if response.status not in [200]: + raise F5ModuleError('Status code: {0}. Unexpected Error: {1} for uri: {2}\nText: {3}'.format( + response.status, 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 ex is not None: - error += ' The reported error was "{0}".'.format(str(ex)) + error = 'Unable to connect to {0} on port {1}.'.format( + self.provider['server'], self.provider['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/common.py b/lib/ansible/module_utils/network/f5/common.py index 60c851d0902..5d7f104b21e 100644 --- a/lib/ansible/module_utils/network/f5/common.py +++ b/lib/ansible/module_utils/network/f5/common.py @@ -295,8 +295,6 @@ def compare_dictionary(want, have): Returns: bool: - :param have: - :return: """ if want == [] and have is None: return None @@ -328,6 +326,26 @@ def exit_json(module, results, client=None): module.exit_json(**results) +def is_uuid(uuid=None): + """Check to see if value is an F5 UUID + + UUIDs are used in BIG-IQ and in select areas of BIG-IP (notably ASM). This method + will check to see if the provided value matches a UUID as known by these products. + + Args: + uuid (string): The value to check for UUID-ness + + Returns: + bool: + """ + if uuid is None: + return False + pattern = r'[A-Za-z0-9]{8}-[A-Za-z0-9]{4}-[A-Za-z0-9]{4}-[A-Za-z0-9]{4}-[A-Za-z0-9]{12}' + if re.match(pattern, uuid): + return True + return False + + class Noop(object): """Represent no-operation required @@ -405,7 +423,7 @@ class F5BaseClient(object): elif self.params.get('auth_provider', None): result['auth_provider'] = self.params.get('auth_provider', None) else: - result['auth_provider'] = 'tmos' + result['auth_provider'] = None if provider.get('user', None): result['user'] = provider.get('user', None) diff --git a/lib/ansible/module_utils/network/f5/icontrol.py b/lib/ansible/module_utils/network/f5/icontrol.py index daf96c1c643..767db724424 100644 --- a/lib/ansible/module_utils/network/f5/icontrol.py +++ b/lib/ansible/module_utils/network/f5/icontrol.py @@ -35,8 +35,8 @@ 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 ``requests`` design was chosen due to familiarity with the tool. Internally, +the classes contained herein use Ansible native libraries. The means by which you should use it are similar to ``requests`` basic usage. @@ -159,7 +159,7 @@ class PreparedRequest(object): class Response(object): def __init__(self): self._content = None - self.status_code = None + self.status = None self.headers = dict() self.url = None self.reason = None @@ -187,9 +187,14 @@ class iControlRestSession(object): self.headers = self.default_headers() self.verify = True self.params = {} - self.auth = None self.timeout = 30 + self.server = None + self.user = None + self.password = None + self.server_port = None + self.auth_provider = None + def _normalize_headers(self, headers): result = {} result.update(dict((k.lower(), v) for k, v in headers)) @@ -259,12 +264,13 @@ class iControlRestSession(object): method=request.method, data=request.body, timeout=kwargs.get('timeout', None) or self.timeout, + validate_certs=kwargs.get('verify', None) or self.verify, headers=request.headers ) try: result = open_url(request.url, **params) - response._content = result.read() + response._content = result.read().decode('utf-8') response.status = result.getcode() response.url = result.geturl() response.msg = "OK (%s bytes)" % result.headers.get('Content-Length', 'unknown') @@ -280,79 +286,19 @@ class iControlRestSession(object): 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 delete(self, url, json=None, **kwargs): + return self.request('DELETE', url, json=json, **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) diff --git a/lib/ansible/plugins/action/bigip.py b/lib/ansible/plugins/action/bigip.py index 8f904c3572d..a8e94d143a7 100644 --- a/lib/ansible/plugins/action/bigip.py +++ b/lib/ansible/plugins/action/bigip.py @@ -19,6 +19,7 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type +import os import sys import copy @@ -43,17 +44,16 @@ except ImportError: class ActionModule(_ActionModule): def run(self, tmp=None, task_vars=None): - del tmp # tmp no longer has any effect + socket_path = None + transport = 'rest' if self._play_context.connection == 'network_cli': provider = self._task.args.get('provider', {}) if any(provider.values()): - display.warning('provider is unnecessary when using network_cli and will be ignored') - del self._task.args['provider'] + display.warning("'provider' is unnecessary when using 'network_cli' and will be ignored") elif self._play_context.connection == 'local': provider = load_provider(f5_provider_spec, self._task.args) - - transport = provider['transport'] or 'rest' + transport = provider['transport'] or transport display.vvvv('connection transport is %s' % transport, self._play_context.remote_addr) @@ -70,17 +70,14 @@ class ActionModule(_ActionModule): display.vvv('using connection plugin %s' % pc.connection, pc.remote_addr) connection = self._shared_loader_obj.connection_loader.get('persistent', pc, sys.stdin) - socket_path = connection.run() display.vvvv('socket_path: %s' % socket_path, pc.remote_addr) if not socket_path: return {'failed': True, - 'msg': 'unable to open shell. Please see: ' + + 'msg': 'Unable to open shell. Please see: ' + 'https://docs.ansible.com/ansible/network_debug_troubleshooting.html#unable-to-open-shell'} task_vars['ansible_socket'] = socket_path - else: - self._task.args['provider'] = ActionModule.rest_implementation(provider, self._play_context) else: return {'failed': True, 'msg': 'Connection type %s is not valid for this module' % self._play_context.connection} @@ -96,61 +93,5 @@ class ActionModule(_ActionModule): conn.send_command('exit') out = conn.get_prompt() - result = super(ActionModule, self).run(task_vars=task_vars) + result = super(ActionModule, self).run(tmp, task_vars) return result - - @staticmethod - def rest_implementation(provider, play_context): - """Provides a generic argument spec using Play context vars - - This method will return a set of default values to use for connecting - to a remote BIG-IP in the event that you do not use either - - * The environment fallback variables F5_USER, F5_PASSWORD, etc - * The "provider" spec - - With this "spec" (for lack of a better name) Ansible will attempt - to fill in the provider arguments itself using the play context variables. - These variables are contained in the list of MAGIC_VARIABLE_MAPPING - found in the constants file - - * https://github.com/ansible/ansible/blob/devel/lib/ansible/constants.py - - Therefore, if you do not use the provider nor that environment args, this - method here will be populate the "provider" dict with with the necessary - F5 connection params, from the following host vars, - - * remote_addr=('ansible_ssh_host', 'ansible_host'), - * remote_user=('ansible_ssh_user', 'ansible_user'), - * password=('ansible_ssh_pass', 'ansible_password'), - * port=('ansible_ssh_port', 'ansible_port'), - * timeout=('ansible_ssh_timeout', 'ansible_timeout'), - * private_key_file=('ansible_ssh_private_key_file', 'ansible_private_key_file'), - - For example, this may leave your inventory looking like this - - bigip2 ansible_host=1.2.3.4 ansible_port=10443 ansible_user=admin ansible_password=admin - - :param provider: - :param play_context: - :return: - """ - provider['transport'] = 'rest' - - if provider.get('server') is None: - provider['server'] = play_context.remote_addr - - if provider.get('server_port') is None: - default_port = provider['server_port'] if provider['server_port'] else 443 - provider['server_port'] = int(play_context.port or default_port) - - if provider.get('timeout') is None: - provider['timeout'] = C.PERSISTENT_COMMAND_TIMEOUT - - if provider.get('user') is None: - provider['user'] = play_context.connection_user - - if provider.get('password') is None: - provider['password'] = play_context.password - - return provider diff --git a/lib/ansible/plugins/action/bigiq.py b/lib/ansible/plugins/action/bigiq.py new file mode 100644 index 00000000000..ddd09c5b336 --- /dev/null +++ b/lib/ansible/plugins/action/bigiq.py @@ -0,0 +1,97 @@ +# +# (c) 2016 Red Hat Inc. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import os +import sys +import copy + +from ansible import constants as C +from ansible.module_utils._text import to_text +from ansible.module_utils.connection import Connection +from ansible.module_utils.network.common.utils import load_provider +from ansible.plugins.action.normal import ActionModule as _ActionModule + +try: + from library.module_utils.network.f5.common import f5_provider_spec +except: + from ansible.module_utils.network.f5.common import f5_provider_spec + +try: + from __main__ import display +except ImportError: + from ansible.utils.display import Display + display = Display() + + +class ActionModule(_ActionModule): + + def run(self, tmp=None, task_vars=None): + socket_path = None + transport = 'rest' + + if self._play_context.connection == 'network_cli': + provider = self._task.args.get('provider', {}) + if any(provider.values()): + display.warning("'provider' is unnecessary when using 'network_cli' and will be ignored") + elif self._play_context.connection == 'local': + provider = load_provider(f5_provider_spec, self._task.args) + transport = provider['transport'] or transport + + display.vvvv('connection transport is %s' % transport, self._play_context.remote_addr) + + if transport == 'cli': + pc = copy.deepcopy(self._play_context) + pc.connection = 'network_cli' + pc.network_os = 'bigiq' + pc.remote_addr = provider.get('server', self._play_context.remote_addr) + pc.port = int(provider['server_port'] or self._play_context.port or 22) + pc.remote_user = provider.get('user', self._play_context.connection_user) + pc.password = provider.get('password', self._play_context.password) + pc.private_key_file = provider['ssh_keyfile'] or self._play_context.private_key_file + pc.timeout = int(provider['timeout'] or C.PERSISTENT_COMMAND_TIMEOUT) + + display.vvv('using connection plugin %s' % pc.connection, pc.remote_addr) + connection = self._shared_loader_obj.connection_loader.get('persistent', pc, sys.stdin) + socket_path = connection.run() + display.vvvv('socket_path: %s' % socket_path, pc.remote_addr) + if not socket_path: + return {'failed': True, + 'msg': 'unable to open shell. Please see: ' + + 'https://docs.ansible.com/ansible/network_debug_troubleshooting.html#unable-to-open-shell'} + + task_vars['ansible_socket'] = socket_path + else: + return {'failed': True, 'msg': 'Connection type %s is not valid for this module' % self._play_context.connection} + + if (self._play_context.connection == 'local' and transport == 'cli') or self._play_context.connection == 'network_cli': + # make sure we are in the right cli context which should be + # enable mode and not config module + if socket_path is None: + socket_path = self._connection.socket_path + conn = Connection(socket_path) + out = conn.get_prompt() + while '(config' in to_text(out, errors='surrogate_then_replace').strip(): + display.vvvv('wrong context, sending exit to device', self._play_context.remote_addr) + conn.send_command('exit') + out = conn.get_prompt() + + result = super(ActionModule, self).run(tmp, task_vars) + return result