From 50052b3d70d1abe451be5217f64a1226253419c2 Mon Sep 17 00:00:00 2001 From: James Mighion Date: Thu, 12 Oct 2017 15:07:15 -0700 Subject: [PATCH] Adding a cli transport option for the bigip_command module. (#30391) * Adding a cli transport option for the bigip_command module. * Fixing keyerror when using other f5 modules. Adding version_added for new option in bigip_command. * Removing local connection check because the F5 tasks can be delegated to any host that has the libraries for REST. * Using the network_common load_provider. * Adding unit test to cover cli transport and updating previous unit test to ensure cli was not called. --- lib/ansible/config/base.yml | 2 +- lib/ansible/module_utils/f5_utils.py | 38 +++++++-- .../modules/network/f5/bigip_command.py | 48 +++++++++--- lib/ansible/plugins/action/bigip.py | 77 +++++++++++++++++++ lib/ansible/plugins/terminal/bigip.py | 52 +++++++++++++ .../modules/network/f5/test_bigip_command.py | 30 +++++++- 6 files changed, 225 insertions(+), 22 deletions(-) create mode 100644 lib/ansible/plugins/action/bigip.py create mode 100644 lib/ansible/plugins/terminal/bigip.py diff --git a/lib/ansible/config/base.yml b/lib/ansible/config/base.yml index 9e6ad2de23..0ace566641 100644 --- a/lib/ansible/config/base.yml +++ b/lib/ansible/config/base.yml @@ -1293,7 +1293,7 @@ MERGE_MULTIPLE_CLI_TAGS: version_added: "2.3" NETWORK_GROUP_MODULES: name: Network module families - default: [eos, nxos, ios, iosxr, junos, enos, ce, vyos, sros, dellos9, dellos10, dellos6, asa, aruba, aireos] + default: [eos, nxos, ios, iosxr, junos, enos, ce, vyos, sros, dellos9, dellos10, dellos6, asa, aruba, aireos, bigip] description: 'TODO: write it' env: [{name: NETWORK_GROUP_MODULES}] ini: diff --git a/lib/ansible/module_utils/f5_utils.py b/lib/ansible/module_utils/f5_utils.py index 7b7aeaeba8..4f3f0d1bac 100644 --- a/lib/ansible/module_utils/f5_utils.py +++ b/lib/ansible/module_utils/f5_utils.py @@ -134,6 +134,28 @@ def fq_list_names(partition, list_names): return map(lambda x: fq_name(partition, x), list_names) +def to_commands(module, commands): + spec = { + 'command': dict(key=True), + 'prompt': dict(), + 'answer': dict() + } + transform = ComplexList(spec, module) + return transform(commands) + + +def run_commands(module, commands, check_rc=True): + responses = list() + commands = to_commands(module, to_list(commands)) + for cmd in commands: + cmd = module.jsonify(cmd) + rc, out, err = exec_command(module, cmd) + if check_rc and rc != 0: + module.fail_json(msg=to_text(err, errors='surrogate_then_replace'), rc=rc) + responses.append(to_text(out, errors='surrogate_then_replace')) + return responses + + # New style from abc import ABCMeta, abstractproperty @@ -154,6 +176,9 @@ except ImportError: from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.six import iteritems, with_metaclass +from ansible.module_utils.network_common import to_list, ComplexList +from ansible.module_utils.connection import exec_command +from ansible.module_utils._text import to_text F5_COMMON_ARGS = dict( @@ -232,12 +257,13 @@ class AnsibleF5Client(object): self.check_mode = self.module.check_mode self._connect_params = self._get_connect_params() - try: - self.api = self._get_mgmt_root( - f5_product_name, **self._connect_params - ) - except iControlUnexpectedHTTPError as exc: - self.fail(str(exc)) + if 'transport' not in self.module.params or self.module.params['transport'] != 'cli': + try: + self.api = self._get_mgmt_root( + f5_product_name, **self._connect_params + ) + except iControlUnexpectedHTTPError as exc: + self.fail(str(exc)) def fail(self, msg): self.module.fail_json(msg=msg) diff --git a/lib/ansible/modules/network/f5/bigip_command.py b/lib/ansible/modules/network/f5/bigip_command.py index b24362de09..a601f4ba61 100644 --- a/lib/ansible/modules/network/f5/bigip_command.py +++ b/lib/ansible/modules/network/f5/bigip_command.py @@ -64,6 +64,17 @@ options: conditional, the interval indicates how to long to wait before trying the command again. default: 1 + transport: + description: + - Configures the transport connection to use when connecting to the + remote device. The transport argument supports connectivity to the + device over cli (ssh) or rest. + required: true + choices: + - rest + - cli + default: rest + version_added: "2.5" notes: - Requires the f5-sdk Python package on the host. This is as easy as pip install f5-sdk. @@ -158,6 +169,7 @@ from ansible.module_utils.f5_utils import AnsibleF5Parameters from ansible.module_utils.f5_utils import HAS_F5SDK from ansible.module_utils.f5_utils import F5ModuleError from ansible.module_utils.f5_utils import iControlUnexpectedHTTPError +from ansible.module_utils.f5_utils import run_commands from ansible.module_utils.netcli import FailedConditionsError from ansible.module_utils.six import string_types from ansible.module_utils.netcli import Conditional @@ -179,10 +191,11 @@ class Parameters(AnsibleF5Parameters): @property def commands(self): commands = deque(self._values['commands']) - commands.appendleft( - 'tmsh modify cli preference pager disabled' - ) - commands = map(self._ensure_tmsh_prefix, list(commands)) + if self._values['transport'] != 'cli': + commands.appendleft( + 'tmsh modify cli preference pager disabled' + ) + commands = map(self._ensure_tmsh_prefix, list(commands)) return list(commands) def _ensure_tmsh_prefix(self, cmd): @@ -208,9 +221,11 @@ class ModuleManager(object): def _is_valid_mode(self, cmd): valid_configs = [ - 'tmsh list', 'tmsh show', - 'tmsh modify cli preference pager disabled' + 'list', 'show', + 'modify cli preference pager disabled' ] + if self.client.module.params['transport'] != 'cli': + valid_configs = list(map(self.want._ensure_tmsh_prefix, valid_configs)) if any(cmd.startswith(x) for x in valid_configs): return True return False @@ -241,12 +256,16 @@ class ModuleManager(object): return while retries > 0: - responses = self.execute_on_device(commands) + if self.client.module.params['transport'] == 'cli': + responses = run_commands(self.client.module, self.want.commands) + else: + responses = self.execute_on_device(commands) for item in list(conditionals): if item(responses): if self.want.match == 'any': - return item + conditionals = list() + break conditionals.remove(item) if not conditionals: @@ -280,7 +299,7 @@ class ModuleManager(object): commands = transform(commands) for index, item in enumerate(commands): - if not self._is_valid_mode(item['command']): + if not self._is_valid_mode(item['command']) and self.client.module.params['transport'] != 'cli': warnings.append( 'Using "write" commands is not idempotent. You should use ' 'a module that is specifically made for that. If such a ' @@ -329,15 +348,17 @@ class ArgumentSpec(object): interval=dict( default=1, type='int' + ), + transport=dict( + type='str', + default='rest', + choices=['cli', 'rest'] ) ) self.f5_product_name = 'bigip' def main(): - if not HAS_F5SDK: - raise F5ModuleError("The python f5-sdk module is required") - spec = ArgumentSpec() client = AnsibleF5Client( @@ -346,6 +367,9 @@ def main(): f5_product_name=spec.f5_product_name ) + if client.module.params['transport'] != 'cli' and not HAS_F5SDK: + raise F5ModuleError("The python f5-sdk module is required to use the rest api") + try: mm = ModuleManager(client) results = mm.exec_module() diff --git a/lib/ansible/plugins/action/bigip.py b/lib/ansible/plugins/action/bigip.py new file mode 100644 index 0000000000..0933751f22 --- /dev/null +++ b/lib/ansible/plugins/action/bigip.py @@ -0,0 +1,77 @@ +# +# (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 sys +import copy + +from ansible import constants as C +from ansible.module_utils.f5_utils import F5_COMMON_ARGS +from ansible.module_utils.network_common import load_provider +from ansible.plugins.action.normal import ActionModule as _ActionModule + +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): + transport = self._task.args.get('transport', 'rest') + + display.vvvv('connection transport is %s' % transport, self._play_context.remote_addr) + + if transport == 'cli': + provider = load_provider(F5_COMMON_ARGS, self._task.args) + self._task.args.pop('provider', None) + pc = copy.deepcopy(self._play_context) + pc.connection = 'network_cli' + pc.network_os = 'bigip' + 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.timeout = int(provider.get('timeout', 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'} + + # make sure we are in the right cli context which should be + # enable mode and not config mode + rc, out, err = connection.exec_command('prompt()') + while '(config' in str(out): + display.vvvv('wrong context, sending exit to device', self._play_context.remote_addr) + connection.exec_command('exit') + rc, out, err = connection.exec_command('prompt()') + + task_vars['ansible_socket'] = socket_path + + result = super(ActionModule, self).run(tmp, task_vars) + return result diff --git a/lib/ansible/plugins/terminal/bigip.py b/lib/ansible/plugins/terminal/bigip.py new file mode 100644 index 0000000000..0da27fda41 --- /dev/null +++ b/lib/ansible/plugins/terminal/bigip.py @@ -0,0 +1,52 @@ +# +# (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 re + +from ansible.plugins.terminal import TerminalBase +from ansible.errors import AnsibleConnectionFailure + + +class TerminalModule(TerminalBase): + + terminal_stdout_re = [ + re.compile(br"[\r\n]?(?:\([^\)]+\)){,5}(?:>|#) ?$"), + re.compile(br"[\r\n]?[\w+\-\.:\/\[\]]+(?:\([^\)]+\)){,3}(?:>|#) ?$"), + re.compile(br"\[\w+\@[\w\-\.]+(?: [^\]])\] ?[>#\$] ?$") + ] + + terminal_stderr_re = [ + re.compile(br"% ?Error"), + re.compile(br"% User not present"), + re.compile(br"% ?Bad secret"), + re.compile(br"invalid input", re.I), + re.compile(br"(?:incomplete|ambiguous) command", re.I), + re.compile(br"connection timed out", re.I), + re.compile(br"[^\r\n]+ not found", re.I), + re.compile(br"'[^']' +returned error code: ?\d+"), + re.compile(br"[^\r\n]\/bin\/(?:ba)?sh") + ] + + def on_open_shell(self): + try: + self._exec_cli_command(b'modify cli preference display-threshold 0 pager disabled') + except AnsibleConnectionFailure: + raise AnsibleConnectionFailure('unable to set terminal parameters') diff --git a/test/units/modules/network/f5/test_bigip_command.py b/test/units/modules/network/f5/test_bigip_command.py index 778acdc7f2..b3009fda80 100644 --- a/test/units/modules/network/f5/test_bigip_command.py +++ b/test/units/modules/network/f5/test_bigip_command.py @@ -67,6 +67,7 @@ def load_fixture(name): class TestParameters(unittest.TestCase): + def test_module_parameters(self): args = dict( commands=[ @@ -85,6 +86,10 @@ class TestParameters(unittest.TestCase): class TestManager(unittest.TestCase): def setUp(self): + self.mock_run_commands = patch('ansible.modules.network.f5.bigip_command.run_commands') + self.run_commands = self.mock_run_commands.start() + self.mock_execute_on_device = patch('ansible.modules.network.f5.bigip_command.ModuleManager.execute_on_device') + self.execute_on_device = self.mock_execute_on_device.start() self.spec = ArgumentSpec() def test_run_single_command(self, *args): @@ -104,9 +109,28 @@ class TestManager(unittest.TestCase): ) mm = ModuleManager(client) - # Override methods to force specific logic in the module to happen - mm.execute_on_device = Mock(return_value='foo') - results = mm.exec_module() assert results['changed'] is True + self.assertEqual(self.run_commands.call_count, 0) + self.assertEqual(self.execute_on_device.call_count, 1) + + def test_cli_command(self, *args): + set_module_args(dict( + commands=[ + "show sys version" + ], + server='localhost', + user='admin', + password='password', + transport='cli' + )) + client = AnsibleF5Client( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + f5_product_name=self.spec.f5_product_name + ) + mm = ModuleManager(client) + results = mm.exec_module() + self.assertEqual(self.run_commands.call_count, 1) + self.assertEqual(self.execute_on_device.call_count, 0)