diff --git a/lib/ansible/modules/network/f5/bigip_qkview.py b/lib/ansible/modules/network/f5/bigip_qkview.py new file mode 100644 index 0000000000..e129cbabf4 --- /dev/null +++ b/lib/ansible/modules/network/f5/bigip_qkview.py @@ -0,0 +1,465 @@ +#!/usr/bin/python +# -*- 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 . + +ANSIBLE_METADATA = { + 'status': ['preview'], + 'supported_by': 'community', + 'metadata_version': '1.0' +} + +DOCUMENTATION = ''' +--- +module: bigip_qkview +short_description: Manage qkviews on the device. +description: + - Manages creating and downloading qkviews from a BIG-IP. Various + options can be provided when creating qkviews. The qkview is important + when dealing with F5 support. It may be required that you upload this + qkview to the supported channels during resolution of an SRs that you + may have opened. +version_added: "2.4" +options: + filename: + description: + - Name of the qkview to create on the remote BIG-IP. + default: "localhost.localdomain.qkview" + dest: + description: + - Destination on your local filesystem when you want to save the qkview. + required: True + asm_request_log: + description: + - When C(True), includes the ASM request log data. When C(False), + excludes the ASM request log data. + default: no + choices: + - yes + - no + max_file_size: + description: + - Max file size, in bytes, of the qkview to create. By default, no max + file size is specified. + default: 0 + complete_information: + description: + - Include complete information in the qkview. + default: yes + choices: + - yes + - no + exclude_core: + description: + - Exclude core files from the qkview. + default: no + choices: + - yes + - no + exclude: + description: + - Exclude various file from the qkview. + choices: + - all + - audit + - secure + - bash_history + force: + description: + - If C(no), the file will only be transferred if the destination does not + exist. + default: yes + choices: + - yes + - no +notes: + - Requires the f5-sdk Python package on the host. This is as easy as pip + install f5-sdk. + - This module does not include the "max time" or "restrict to blade" options. +requirements: + - f5-sdk >= 2.2.3 +extends_documentation_fragment: f5 +author: + - Tim Rupp (@caphrim007) +''' + +EXAMPLES = ''' +- name: Fetch a qkview from the remote device + bigip_qkview: + asm_request_log: "yes" + exclude: + - audit + - secure + dest: "/tmp/localhost.localdomain.qkview" + 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: [['...', '...'], ['...'], ['...']] +''' + +import re +import os + +from distutils.version import LooseVersion + +from ansible.module_utils.six import string_types +from ansible.module_utils.f5_utils import ( + AnsibleF5Client, + AnsibleF5Parameters, + HAS_F5SDK, + F5ModuleError, + iControlUnexpectedHTTPError +) + + +class Parameters(AnsibleF5Parameters): + api_attributes = [ + 'exclude', 'exclude_core', 'complete_information', 'max_file_size', + 'asm_request_log', 'filename_cmd' + ] + + returnables = ['stdout', 'stdout_lines', 'warnings'] + + @property + def exclude(self): + if self._values['exclude'] is None: + return None + exclude = ' '.join(self._values['exclude']) + return "--exclude='{0}'".format(exclude) + + @property + def exclude_raw(self): + return self._values['exclude'] + + @property + def exclude_core(self): + if self._values['exclude']: + return '-C' + else: + return None + + @property + def complete_information(self): + if self._values['complete_information']: + return '-c' + return None + + @property + def max_file_size(self): + if self._values['max_file_size'] in [None, 0]: + return '-s0' + return '-s {0}'.format(self._values['max_file_size']) + + @property + def asm_request_log(self): + if self._values['asm_request_log']: + return '-o asm-request-log' + return None + + @property + def filename(self): + pattern = r'^[\w\.]+$' + filename = os.path.basename(self._values['filename']) + if re.match(pattern, filename): + return filename + else: + raise F5ModuleError( + "The provided filename must contain word characters only." + ) + + @property + def filename_cmd(self): + return '-f {0}'.format(self.filename) + + 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 + + def api_params(self): + result = {} + for api_attribute in self.api_attributes: + if self.api_map is not None and api_attribute in self.api_map: + result[api_attribute] = getattr(self, self.api_map[api_attribute]) + else: + result[api_attribute] = getattr(self, api_attribute) + result = self._filter_params(result) + return result + + +class ModuleManager(object): + def __init__(self, client): + self.client = client + + def exec_module(self): + if self.is_version_less_than_14(): + manager = self.get_manager('madm') + else: + manager = self.get_manager('bulk') + return manager.exec_module() + + def get_manager(self, type): + if type == 'madm': + return MadmLocationManager(self.client) + elif type == 'bulk': + return BulkLocationManager(self.client) + + def is_version_less_than_14(self): + """Checks to see if the TMOS version is less than 14 + + Anything less than BIG-IP 13.x does not support users + on different partitions. + + :return: Bool + """ + version = self.client.api.tmos_version + if LooseVersion(version) < LooseVersion('14.0.0'): + return True + else: + return False + + +class BaseManager(object): + def __init__(self, client): + self.client = client + self.want = Parameters(self.client.module.params) + self.changes = Parameters() + + def _set_changed_options(self): + changed = {} + for key in Parameters.returnables: + if getattr(self.want, key) is not None: + changed[key] = getattr(self.want, key) + if changed: + self.changes = Parameters(changed) + + def _to_lines(self, stdout): + lines = [] + if isinstance(stdout, string_types): + lines = str(stdout).split('\n') + return lines + + def exec_module(self): + result = dict() + + try: + self.present() + except iControlUnexpectedHTTPError as e: + raise F5ModuleError(str(e)) + + result.update(**self.changes.to_return()) + result.update(dict(changed=False)) + return result + + def present(self): + if os.path.exists(self.want.dest) and not self.want.force: + raise F5ModuleError( + "The specified 'dest' file already exists" + ) + if self.want.exclude: + choices = ['all', 'audit', 'secure', 'bash_history'] + if not all(x in choices for x in self.want.exclude_raw): + raise F5ModuleError( + "The specified excludes must be in the following list: " + "{0}".format(','.join(choices)) + ) + self.execute() + + def exists(self): + ls = self.client.api.tm.util.unix_ls.exec_cmd( + 'run', utilCmdArgs=self.remote_dir + ) + + # Empty directories return nothing to the commandResult + if not hasattr(ls, 'commandResult'): + return False + + if self.want.filename in ls.commandResult: + return True + else: + return False + + def execute(self): + response = self.execute_on_device() + result = self._move_qkview_to_download() + if not result: + raise F5ModuleError( + "Failed to move the file to a downloadable location" + ) + + self._download_file() + if not os.path.exists(self.want.dest): + raise F5ModuleError( + "Failed to save the qkview to local disk" + ) + + self._delete_qkview() + result = self.exists() + if result: + raise F5ModuleError( + "Failed to remove the remote qkview" + ) + + self.changes = Parameters({ + 'stdout': response, + 'stdout_lines': self._to_lines(response) + }) + + def _delete_qkview(self): + tpath_name = '{0}/{1}'.format(self.remote_dir, self.want.filename) + self.client.api.tm.util.unix_rm.exec_cmd( + 'run', utilCmdArgs=tpath_name + ) + + def execute_on_device(self): + params = self.want.api_params().values() + output = self.client.api.tm.util.qkview.exec_cmd( + 'run', + utilCmdArgs='{0}'.format(' '.join(params)) + ) + if hasattr(output, 'commandResult'): + return str(output.commandResult) + return None + + +class BulkLocationManager(BaseManager): + def __init__(self, client): + super(BulkLocationManager, self).__init__(client) + self.remote_dir = '/var/config/rest/bulk' + + def _move_qkview_to_download(self): + try: + move_path = '/var/tmp/{0} {1}/{0}'.format( + self.want.filename, self.remote_dir + ) + self.client.api.tm.util.unix_mv.exec_cmd( + 'run', + utilCmdArgs=move_path + ) + return True + except Exception: + return False + + def _download_file(self): + bulk = self.client.api.shared.file_transfer.bulk + bulk.download_file(self.want.filename, self.want.dest) + if os.path.exists(self.want.dest): + return True + return False + + +class MadmLocationManager(BaseManager): + def __init__(self, client): + super(MadmLocationManager, self).__init__(client) + self.remote_dir = '/var/config/rest/madm' + + def _move_qkview_to_download(self): + try: + move_path = '/var/tmp/{0} {1}/{0}'.format( + self.want.filename, self.remote_dir + ) + self.client.api.tm.util.unix_mv.exec_cmd( + 'run', + utilCmdArgs=move_path + ) + return True + except Exception: + return False + + def _download_file(self): + madm = self.client.api.shared.file_transfer.madm + madm.download_file(self.want.filename, self.want.dest) + if os.path.exists(self.want.dest): + return True + return False + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + self.argument_spec = dict( + filename=dict( + default='localhost.localdomain.qkview' + ), + asm_request_log=dict( + type='bool', + default=False, + ), + max_file_size=dict( + type='int', + ), + complete_information=dict( + default=False, + type='bool' + ), + exclude_core=dict( + default=False, + type='bool' + ), + force=dict( + default=True, + type='bool' + ), + exclude=dict( + type='list' + ), + dest=dict( + type='path', + required=True + ) + ) + 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 F5ModuleError as e: + client.module.fail_json(msg=str(e)) + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/network/f5/test_bigip_qkview.py b/test/units/modules/network/f5/test_bigip_qkview.py new file mode 100644 index 0000000000..b9de94f562 --- /dev/null +++ b/test/units/modules/network/f5/test_bigip_qkview.py @@ -0,0 +1,191 @@ +# -*- 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 os +import json +import sys + +from nose.plugins.skip import SkipTest +if sys.version_info < (2, 7): + raise SkipTest("F5 Ansible modules require Python >= 2.7") + +from ansible.compat.tests import unittest +from ansible.compat.tests.mock import patch, Mock +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes +from ansible.module_utils.f5_utils import AnsibleF5Client + +try: + from library.bigip_qkview import Parameters + from library.bigip_qkview import ModuleManager + from library.bigip_qkview import MadmLocationManager + from library.bigip_qkview import BulkLocationManager + from library.bigip_qkview import ArgumentSpec +except ImportError: + try: + from ansible.modules.network.f5.bigip_qkview import Parameters + from ansible.modules.network.f5.bigip_qkview import ModuleManager + from ansible.modules.network.f5.bigip_qkview import MadmLocationManager + from ansible.modules.network.f5.bigip_qkview import BulkLocationManager + from ansible.modules.network.f5.bigip_qkview import ArgumentSpec + except ImportError: + raise SkipTest("F5 Ansible modules require the f5-sdk Python library") + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +def set_module_args(args): + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +def load_fixture(name): + path = os.path.join(fixture_path, name) + + if path in fixture_data: + return fixture_data[path] + + with open(path) as f: + data = f.read() + + try: + data = json.loads(data) + except Exception: + pass + + fixture_data[path] = data + return data + + +class TestParameters(unittest.TestCase): + def test_module_parameters(self): + args = dict( + filename='foo.qkview', + asm_request_log=False, + max_file_size=1024, + complete_information=True, + exclude_core=True, + force=False, + exclude=['audit', 'secure'], + dest='/tmp/foo.qkview' + ) + p = Parameters(args) + assert p.filename == 'foo.qkview' + assert p.asm_request_log is None + assert p.max_file_size == '-s 1024' + assert p.complete_information == '-c' + assert p.exclude_core == '-C' + assert p.force is False + assert len(p.exclude_core) == 2 + assert 'audit' in p.exclude + assert 'secure' in p.exclude + assert p.dest == '/tmp/foo.qkview' + + def test_module_asm_parameter(self): + args = dict( + asm_request_log=True, + ) + p = Parameters(args) + assert p.asm_request_log == '-o asm-request-log' + + +@patch('ansible.module_utils.f5_utils.AnsibleF5Client._get_mgmt_root', + return_value=True) +class TestMadmLocationManager(unittest.TestCase): + + def setUp(self): + self.spec = ArgumentSpec() + + def test_create_qkview_default_options(self, *args): + set_module_args(dict( + dest='/tmp/foo.qkview', + 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 + ) + + # Override methods in the specific type of manager + tm = MadmLocationManager(client) + tm.exists = Mock(return_value=False) + tm.execute_on_device = Mock(return_value=True) + tm._move_qkview_to_download = Mock(return_value=True) + tm._download_file = Mock(return_value=True) + tm._delete_qkview = Mock(return_value=True) + + # Override methods to force specific logic in the module to happen + mm = ModuleManager(client) + mm.is_version_less_than_14 = Mock(return_value=True) + mm.get_manager = Mock(return_value=tm) + + with patch('os.path.exists') as mo: + mo.return_value = True + results = mm.exec_module() + + assert results['changed'] is False + + +@patch('ansible.module_utils.f5_utils.AnsibleF5Client._get_mgmt_root', + return_value=True) +class TestBulkLocationManager(unittest.TestCase): + + def setUp(self): + self.spec = ArgumentSpec() + + def test_create_qkview_default_options(self, *args): + set_module_args(dict( + dest='/tmp/foo.qkview', + 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 + ) + + # Override methods in the specific type of manager + tm = BulkLocationManager(client) + tm.exists = Mock(return_value=False) + tm.execute_on_device = Mock(return_value=True) + tm._move_qkview_to_download = Mock(return_value=True) + tm._download_file = Mock(return_value=True) + tm._delete_qkview = Mock(return_value=True) + + # Override methods to force specific logic in the module to happen + mm = ModuleManager(client) + mm.is_version_less_than_14 = Mock(return_value=False) + mm.get_manager = Mock(return_value=tm) + + with patch('os.path.exists') as mo: + mo.return_value = True + results = mm.exec_module() + + assert results['changed'] is False