From 4fc40304d5b8ab6e4e2397973d5d5b2bc3901f2b Mon Sep 17 00:00:00 2001 From: Tim Rupp Date: Mon, 22 May 2017 12:11:34 -0700 Subject: [PATCH] Adds the bigip_command module to Ansible (#24850) This patch adds the bigip_command module to Ansible to support arbitrary tmsh command to a F5 BIG-IP. --- .../modules/network/f5/bigip_command.py | 375 ++++++++++++++++++ .../modules/network/f5/test_bigip_command.py | 109 +++++ 2 files changed, 484 insertions(+) create mode 100644 lib/ansible/modules/network/f5/bigip_command.py create mode 100644 test/units/modules/network/f5/test_bigip_command.py diff --git a/lib/ansible/modules/network/f5/bigip_command.py b/lib/ansible/modules/network/f5/bigip_command.py new file mode 100644 index 00000000000..a4f767c4deb --- /dev/null +++ b/lib/ansible/modules/network/f5/bigip_command.py @@ -0,0 +1,375 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright 2016 F5 Networks Inc. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +ANSIBLE_METADATA = { + 'status': ['preview'], + 'supported_by': 'community', + 'metadata_version': '1.0' +} + +DOCUMENTATION = ''' +--- +module: bigip_command +short_description: Run arbitrary command on F5 devices. +description: + - Sends an arbitrary command to an BIG-IP node and returns the results + read from the device. This module includes an argument that will cause + the module to wait for a specific condition before returning or timing + out if the condition is not met. +version_added: "2.4" +options: + commands: + description: + - The commands to send to the remote BIG-IP device over the + configured provider. The resulting output from the command + is returned. If the I(wait_for) argument is provided, the + module is not returned until the condition is satisfied or + the number of retries as expired. + - The I(commands) argument also accepts an alternative form + that allows for complex values that specify the command + to run and the output format to return. This can be done + on a command by command basis. The complex argument supports + the keywords C(command) and C(output) where C(command) is the + command to run and C(output) is 'text' or 'one-line'. + required: True + wait_for: + description: + - Specifies what to evaluate from the output of the command + and what conditionals to apply. This argument will cause + the task to wait for a particular conditional to be true + before moving forward. If the conditional is not true + by the configured retries, the task fails. See examples. + aliases: ['waitfor'] + match: + description: + - The I(match) argument is used in conjunction with the + I(wait_for) argument to specify the match policy. Valid + values are C(all) or C(any). If the value is set to C(all) + then all conditionals in the I(wait_for) must be satisfied. If + the value is set to C(any) then only one of the values must be + satisfied. + default: all + retries: + description: + - Specifies the number of retries a command should by tried + before it is considered failed. The command is run on the + target device every retry and evaluated against the I(wait_for) + conditionals. + default: 10 + interval: + description: + - Configures the interval in seconds to wait between retries + of the command. If the command does not pass the specified + conditional, the interval indicates how to long to wait before + trying the command again. + default: 1 +notes: + - Requires the f5-sdk Python package on the host. This is as easy as pip + install f5-sdk. + - Requires Ansible >= 2.3. +requirements: + - f5-sdk >= 2.2.3 +extends_documentation_fragment: f5 +author: + - Tim Rupp (@caphrim007) +''' + +EXAMPLES = ''' +- name: run show version on remote devices + bigip_command: + commands: show sys version + server: "lb.mydomain.com" + password: "secret" + user: "admin" + validate_certs: "no" + delegate_to: localhost + +- name: run show version and check to see if output contains BIG-IP + bigip_command: + commands: show sys version + wait_for: result[0] contains BIG-IP + server: "lb.mydomain.com" + password: "secret" + user: "admin" + validate_certs: "no" + delegate_to: localhost + +- name: run multiple commands on remote nodes + bigip_command: + commands: + - show sys version + - list ltm virtual + server: "lb.mydomain.com" + password: "secret" + user: "admin" + validate_certs: "no" + delegate_to: localhost + +- name: run multiple commands and evaluate the output + bigip_command: + commands: + - show sys version + - list ltm virtual + wait_for: + - result[0] contains BIG-IP + - result[1] contains my-vs + server: "lb.mydomain.com" + password: "secret" + user: "admin" + validate_certs: "no" + delegate_to: localhost + +- name: tmsh prefixes will automatically be handled + bigip_command: + commands: + - show sys version + - tmsh list ltm virtual + server: "lb.mydomain.com" + password: "secret" + user: "admin" + validate_certs: "no" + delegate_to: localhost +''' + +RETURN = ''' +stdout: + description: The set of responses from the commands + returned: always + type: list + sample: ['...', '...'] + +stdout_lines: + description: The value of stdout split into a list + returned: always + type: list + sample: [['...', '...'], ['...'], ['...']] + +failed_conditions: + description: The list of conditionals that have failed + returned: failed + type: list + sample: ['...', '...'] +''' + +import time + +from ansible.module_utils.f5_utils import AnsibleF5Client +from ansible.module_utils.f5_utils import AnsibleF5Parameters +from ansible.module_utils.f5_utils import HAS_F5SDK +from ansible.module_utils.f5_utils import F5ModuleError +from ansible.module_utils.f5_utils import iControlUnexpectedHTTPError +from ansible.module_utils.netcli import FailedConditionsError +from ansible.module_utils.six import string_types +from ansible.module_utils.netcli import Conditional +from ansible.module_utils.network_common import ComplexList +from ansible.module_utils.network_common import to_list +from collections import deque + + +class Parameters(AnsibleF5Parameters): + returnables = ['stdout', 'stdout_lines', 'warnings'] + + def to_return(self): + result = {} + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + return result + + @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)) + return list(commands) + + def _ensure_tmsh_prefix(self, cmd): + cmd = cmd.strip() + if cmd[0:5] != 'tmsh ': + cmd = 'tmsh ' + cmd.strip() + return cmd + + +class ModuleManager(object): + def __init__(self, client): + self.client = client + self.want = Parameters(self.client.module.params) + self.changes = Parameters() + + def _to_lines(self, stdout): + lines = list() + for item in stdout: + if isinstance(item, string_types): + item = str(item).split('\n') + lines.append(item) + return lines + + def _is_valid_mode(self, cmd): + valid_configs = [ + 'tmsh list', 'tmsh show', + 'tmsh modify cli preference pager disabled' + ] + if any(cmd.startswith(x) for x in valid_configs): + return True + return False + + def exec_module(self): + result = dict() + + try: + self.execute() + except iControlUnexpectedHTTPError as e: + raise F5ModuleError(str(e)) + + result.update(**self.changes.to_return()) + result.update(dict(changed=True)) + return result + + def execute(self): + warnings = list() + + commands = self.parse_commands(warnings) + + wait_for = self.want.wait_for or list() + retries = self.want.retries + + conditionals = [Conditional(c) for c in wait_for] + + if self.client.check_mode: + return + + while retries > 0: + responses = self.execute_on_device(commands) + + for item in list(conditionals): + if item(responses): + if self.want.match == 'any': + return item + conditionals.remove(item) + + if not conditionals: + break + + time.sleep(self.want.interval) + retries -= 1 + else: + failed_conditions = [item.raw for item in conditionals] + errmsg = 'One or more conditional statements have not been satisfied' + raise FailedConditionsError(errmsg, failed_conditions) + + self.changes = Parameters({ + 'stdout': responses, + 'stdout_lines': self._to_lines(responses), + 'warnings': warnings + }) + + def parse_commands(self, warnings): + results = [] + commands = list(deque(set(self.want.commands))) + spec = dict( + command=dict(key=True), + output=dict( + default='text', + choices=['text', 'one-line'] + ), + ) + + transform = ComplexList(spec, self.client.module) + commands = transform(commands) + + for index, item in enumerate(commands): + if not self._is_valid_mode(item['command']): + warnings.append( + 'Using "write" commands is not idempotent. You should use ' + 'a module that is specifically made for that. If such a ' + 'module does not exist, then please file a bug. The command ' + 'in question is "%s..."' % item['command'][0:40] + ) + if item['output'] == 'one-line' and 'one-line' not in item['command']: + item['command'] += ' one-line' + elif item['output'] == 'text' and 'one-line' in item['command']: + item['command'] = item['command'].replace('one-line', '') + results.append(item) + return results + + def execute_on_device(self, commands): + responses = [] + for item in to_list(commands): + output = self.client.api.tm.util.bash.exec_cmd( + 'run', + utilCmdArgs='-c "{0}"'.format(item['command']) + ) + if hasattr(output, 'commandResult'): + responses.append(str(output.commandResult)) + return responses + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + self.argument_spec = dict( + commands=dict( + type='list', + required=True + ), + wait_for=dict( + type='list', + aliases=['waitfor'] + ), + match=dict( + default='all', + choices=['any', 'all'] + ), + retries=dict( + default=10, + type='int' + ), + interval=dict( + default=1, + type='int' + ) + ) + self.f5_product_name = 'bigip' + + +def main(): + if not HAS_F5SDK: + raise F5ModuleError("The python f5-sdk module is required") + + spec = ArgumentSpec() + + client = AnsibleF5Client( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + f5_product_name=spec.f5_product_name + ) + + try: + mm = ModuleManager(client) + results = mm.exec_module() + client.module.exit_json(**results) + except (FailedConditionsError, AttributeError) as e: + client.module.fail_json(msg=str(e)) + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/network/f5/test_bigip_command.py b/test/units/modules/network/f5/test_bigip_command.py new file mode 100644 index 00000000000..4d3254ede02 --- /dev/null +++ b/test/units/modules/network/f5/test_bigip_command.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2017 F5 Networks Inc. +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public 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 + +if sys.version_info < (2, 7): + from nose.plugins.skip import SkipTest + raise SkipTest("F5 Ansible modules require Python >= 2.7") + +import os +import json + +from ansible.compat.tests import unittest +from ansible.compat.tests.mock import patch, Mock +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes +from ansible.module_utils.f5_utils import AnsibleF5Client + +try: + from library.bigip_command import Parameters + from library.bigip_command import ModuleManager + from library.bigip_command import ArgumentSpec +except ImportError: + from ansible.modules.network.f5.bigip_command import Parameters + from ansible.modules.network.f5.bigip_command import ModuleManager + from ansible.modules.network.f5.bigip_command import ArgumentSpec + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') + + +def set_module_args(args): + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +def load_fixture(name): + path = os.path.join(fixture_path, name) + with open(path) as f: + data = f.read() + try: + data = json.loads(data) + except Exception: + pass + return data + + +class TestParameters(unittest.TestCase): + def test_module_parameters(self): + args = dict( + commands=[ + "tmsh show sys version" + ], + server='localhost', + user='admin', + password='password' + ) + p = Parameters(args) + assert len(p.commands) == 2 + + +@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_run_single_command(self, *args): + set_module_args(dict( + commands=[ + "tmsh show sys version" + ], + server='localhost', + user='admin', + password='password' + )) + + 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) + + # 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