From 0e1dca6e8fae229e39489f558e121983b8301361 Mon Sep 17 00:00:00 2001 From: Tim Rupp Date: Fri, 16 Nov 2018 09:17:57 -0800 Subject: [PATCH] Adds the bigip_imish_config module. (#48779) This can be used to manage bgp configuration on a BIG-IP. --- .../modules/network/f5/bigip_imish_config.py | 745 ++++++++++++++++++ .../plugins/action/bigip_imish_config.py | 122 +++ .../f5/fixtures/load_imish_output_1.json | 6 + .../network/f5/test_bigip_imish_config.py | 109 +++ 4 files changed, 982 insertions(+) create mode 100644 lib/ansible/modules/network/f5/bigip_imish_config.py create mode 100644 lib/ansible/plugins/action/bigip_imish_config.py create mode 100644 test/units/modules/network/f5/fixtures/load_imish_output_1.json create mode 100644 test/units/modules/network/f5/test_bigip_imish_config.py diff --git a/lib/ansible/modules/network/f5/bigip_imish_config.py b/lib/ansible/modules/network/f5/bigip_imish_config.py new file mode 100644 index 0000000000..278677beda --- /dev/null +++ b/lib/ansible/modules/network/f5/bigip_imish_config.py @@ -0,0 +1,745 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2018, 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': 'certified'} + +DOCUMENTATION = r''' +--- +module: bigip_imish_config +short_description: Manage BIG-IP advanced routing configuration sections +description: + - This module provides an implementation for working with advanced routing + configuration sections in a deterministic way. +version_added: 2.8 +options: + route_domain: + description: + - Route domain to manage BGP configuration on. + default: 0 + lines: + description: + - The ordered set of commands that should be configured in the + section. + - The commands must be the exact same commands as found in the device + running-config. + - Be sure to note the configuration command syntax as some commands + are automatically modified by the device config parser. + aliases: ['commands'] + parents: + description: + - The ordered set of parents that uniquely identify the section or hierarchy + the commands should be checked against. + - If the C(parents) argument is omitted, the commands are checked against + the set of top level or global commands. + src: + description: + - The I(src) argument provides a path to the configuration file + to load into the remote system. + - The path can either be a full system path to the configuration + file if the value starts with / or relative to the root of the + implemented role or playbook. + - This argument is mutually exclusive with the I(lines) and + I(parents) arguments. + before: + description: + - The ordered set of commands to push on to the command stack if + a change needs to be made. + - This allows the playbook designer the opportunity to perform + configuration commands prior to pushing any changes without + affecting how the set of commands are matched against the system. + after: + description: + - The ordered set of commands to append to the end of the command + stack if a change needs to be made. + - Just like with I(before) this allows the playbook designer to + append a set of commands to be executed after the command set. + match: + description: + - Instructs the module on the way to perform the matching of + the set of commands against the current device config. + - If match is set to I(line), commands are matched line by line. + - If match is set to I(strict), command lines are matched with respect + to position. + - If match is set to I(exact), command lines must be an equal match. + - Finally, if match is set to I(none), the module will not attempt to + compare the source configuration with the running configuration on + the remote device. + default: line + choices: ['line', 'strict', 'exact', 'none'] + replace: + description: + - Instructs the module on the way to perform the configuration + on the device. + - If the replace argument is set to I(line) then the modified lines + are pushed to the device in configuration mode. + - If the replace argument is set to I(block) then the entire + command block is pushed to the device in configuration mode if any + line is not correct. + default: line + choices: ['line', 'block'] + backup: + description: + - This argument will cause the module to create a full backup of + the current C(running-config) from the remote device before any + changes are made. + - The backup file is written to the C(backup) folder in the playbook + root directory or role root directory, if playbook is part of an + ansible role. If the directory does not exist, it is created. + type: bool + default: 'no' + running_config: + description: + - The module, by default, will connect to the remote device and + retrieve the current running-config to use as a base for comparing + against the contents of source. + - There are times when it is not desirable to have the task get the + current running-config for every task in a playbook. + - The I(running_config) argument allows the implementer to pass in + the configuration to use as the base config for comparison. + aliases: ['config'] + save_when: + description: + - When changes are made to the device running-configuration, the + changes are not copied to non-volatile storage by default. + - If the argument is set to I(always), then the running-config will + always be copied to the startup-config and the I(modified) flag will + always be set to C(True). + - If the argument is set to I(modified), then the running-config + will only be copied to the startup-config if it has changed since + the last save to startup-config. + - If the argument is set to I(never), the running-config will never be + copied to the startup-config. + - If the argument is set to I(changed), then the running-config + will only be copied to the startup-config if the task has made a change. + default: never + choices: ['always', 'never', 'modified', 'changed'] + diff_against: + description: + - When using the C(ansible-playbook --diff) command line argument + the module can generate diffs against different sources. + - When this option is configure as I(startup), the module will return + the diff of the running-config against the startup-config. + - When this option is configured as I(intended), the module will + return the diff of the running-config against the configuration + provided in the C(intended_config) argument. + - When this option is configured as I(running), the module will + return the before and after diff of the running-config with respect + to any changes made to the device configuration. + default: startup + choices: ['startup', 'intended', 'running'] + diff_ignore_lines: + description: + - Use this argument to specify one or more lines that should be + ignored during the diff. + - This is used for lines in the configuration that are automatically + updated by the system. + - This argument takes a list of regular expressions or exact line matches. + intended_config: + description: + - The C(intended_config) provides the master configuration that + the node should conform to and is used to check the final + running-config against. + - This argument will not modify any settings on the remote device and + is strictly used to check the compliance of the current device's + configuration against. + - When specifying this argument, the task should also modify the + C(diff_against) value and set it to I(intended). +notes: + - Abbreviated commands are NOT idempotent, see + L(Network FAQ,../network/user_guide/faq.html#why-do-the-config-modules-always-return-changed-true-with-abbreviated-commands). +extends_documentation_fragment: f5 +author: + - Tim Rupp (@caphrim007) +''' + +EXAMPLES = r''' +- name: configure top level configuration and save it + bigip_imish_config: + lines: bfd slow-timer 2000 + save_when: modified + +- name: diff the running-config against a provided config + bigip_imish_config: + diff_against: intended + intended_config: "{{ lookup('file', 'master.cfg') }}" + +- name: Add config to a parent block + bigip_imish_config: + lines: + - bgp graceful-restart restart-time 120 + - redistribute kernel route-map rhi + - neighbor 10.10.10.11 remote-as 65000 + - neighbor 10.10.10.11 fall-over bfd + - neighbor 10.10.10.11 remote-as 65000 + - neighbor 10.10.10.11 fall-over bfd + parents: router bgp 64664 + match: exact + +- name: Remove an existing acl before writing it + bigip_imish_config: + lines: + - access-list 10 permit 20.20.20.20 + - access-list 10 permit 20.20.20.21 + - access-list 10 deny any + before: no access-list 10 + +- name: for idempotency, use full-form commands + bigip_imish_config: + lines: + # - desc My interface + - description My Interface + # parents: int ANYCAST-P2P-2 + parents: interface ANYCAST-P2P-2 +''' + +RETURN = r''' +commands: + description: The set of commands that will be pushed to the remote device + returned: always + type: list + sample: ['interface ANYCAST-P2P-2', 'neighbor 20.20.20.21 remote-as 65000', 'neighbor 20.20.20.21 fall-over bfd'] +updates: + description: The set of commands that will be pushed to the remote device + returned: always + type: list + sample: ['interface ANYCAST-P2P-2', 'neighbor 20.20.20.21 remote-as 65000', 'neighbor 20.20.20.21 fall-over bfd'] +backup_path: + description: The full path to the backup file + returned: when backup is yes + type: string + sample: /playbooks/ansible/backup/bigip_imish_config.2016-07-16@22:28:34 +''' + + +try: + from StringIO import StringIO +except ImportError: + from io import StringIO + + +import os +import tempfile + +from ansible.module_utils.network.common.config import NetworkConfig, dumps +from ansible.module_utils.network.common.utils import to_list +from ansible.module_utils.basic import AnsibleModule + +try: + from library.module_utils.network.f5.bigip import F5RestClient + 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 fq_name + from library.module_utils.network.f5.common import f5_argument_spec + from library.module_utils.network.f5.common import exit_json + from library.module_utils.network.f5.common import fail_json + from library.module_utils.network.f5.icontrol import upload_file +except ImportError: + from ansible.module_utils.network.f5.bigip import F5RestClient + 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 fq_name + from ansible.module_utils.network.f5.common import f5_argument_spec + from ansible.module_utils.network.f5.common import exit_json + from ansible.module_utils.network.f5.common import fail_json + from ansible.module_utils.network.f5.icontrol import upload_file + + +class Parameters(AnsibleF5Parameters): + api_map = { + + } + + api_attributes = [ + + ] + + returnables = [ + '__backup__', + 'commands', + 'updates' + ] + + updatables = [ + + ] + + +class ApiParameters(Parameters): + pass + + +class ModuleParameters(Parameters): + pass + + +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 + + +class ModuleManager(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 _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 = UsableChanges(params=changed) + + def _update_changed_options(self): + diff = Difference(self.want, self.have) + updatables = Parameters.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 should_update(self): + result = self._update_changed_options() + if result: + return True + return False + + def exec_module(self): + result = dict() + changed = self.present() + + reportable = ReportableChanges(params=self.changes.to_return()) + changes = reportable.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + return result + + def present(self): + result = dict(changed=False) + config = None + contents = None + + if self.want.backup or (self.module._diff and self.want.diff_against == 'running'): + contents = self.read_current_from_device() + config = NetworkConfig(indent=1, contents=contents) + if self.want.backup: + # The backup file is created in the bigip_imish_config action plugin. Refer + # to that if you have questions. The key below is removed by the action plugin. + result['__backup__'] = contents + + if any((self.want.src, self.want.lines)): + match = self.want.match + replace = self.want.replace + + candidate = self.get_candidate() + running = self.get_running_config(contents) + + response = self.get_diff( + candidate=candidate, + running=running, + diff_match=match, + diff_ignore_lines=self.want.diff_ignore_lines, + path=self.want.parents, + diff_replace=replace + ) + + config_diff = response['config_diff'] + + if config_diff: + commands = config_diff.split('\n') + + if self.want.before: + commands[:0] = self.want.before + + if self.want.after: + commands.extend(self.want.after) + + result['commands'] = commands + result['updates'] = commands + + if not self.module.check_mode: + self.load_config(commands) + + result['changed'] = True + + running_config = self.want.running_config + startup_config = None + + if self.want.save_when == 'always': + self.save_config(result) + elif self.want.save_when == 'modified': + output = self.execute_show_commands(['show running-config', 'show startup-config']) + + running_config = NetworkConfig(indent=1, contents=output[0], ignore_lines=self.want.diff_ignore_lines) + startup_config = NetworkConfig(indent=1, contents=output[1], ignore_lines=self.want.diff_ignore_lines) + + if running_config.sha1 != startup_config.sha1: + self.save_config(result) + elif self.want.save_when == 'changed' and result['changed']: + self.save_on_device() + + if self.module._diff: + if not running_config: + output = self.execute_show_commands('show running-config') + contents = output[0] + else: + contents = running_config + + # recreate the object in order to process diff_ignore_lines + running_config = NetworkConfig(indent=1, contents=contents, ignore_lines=self.want.diff_ignore_lines) + + if self.want.diff_against == 'running': + if self.module.check_mode: + self.module.warn("unable to perform diff against running-config due to check mode") + contents = None + else: + contents = config.config_text + + elif self.want.diff_against == 'startup': + if not startup_config: + output = self.execute_show_commands('show startup-config') + contents = output[0] + else: + contents = startup_config.config_text + + elif self.want.diff_against == 'intended': + contents = self.want.intended_config + + if contents is not None: + base_config = NetworkConfig(indent=1, contents=contents, ignore_lines=self.want.diff_ignore_lines) + + if running_config.sha1 != base_config.sha1: + if self.want.diff_against == 'intended': + before = running_config + after = base_config + elif self.want.diff_against in ('startup', 'running'): + before = base_config + after = running_config + + result.update({ + 'changed': True, + 'diff': {'before': str(before), 'after': str(after)} + }) + self.changes.update(result) + return result['changed'] + + def load_config(self, commands): + content = StringIO("\n".join(commands)) + + file = tempfile.NamedTemporaryFile() + name = os.path.basename(file.name) + + self.upload_file_to_device(content, name) + self.load_config_on_device(name) + self.remove_uploaded_file_from_device(name) + + def remove_uploaded_file_from_device(self, name): + filepath = '/var/config/rest/downloads/{0}'.format(name) + params = { + "command": "run", + "utilCmdArgs": filepath + } + uri = "https://{0}:{1}/mgmt/tm/util/unix-rm".format( + self.client.provider['server'], + self.client.provider['server_port'] + ) + resp = self.client.api.post(uri, json=params) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + if 'code' in response and response['code'] in [400, 403]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + def upload_file_to_device(self, content, name): + url = 'https://{0}:{1}/mgmt/shared/file-transfer/uploads'.format( + self.client.provider['server'], + self.client.provider['server_port'] + ) + try: + upload_file(self.client, url, content, name) + except F5ModuleError: + raise F5ModuleError( + "Failed to upload the file." + ) + + def load_config_on_device(self, name): + filepath = '/var/config/rest/downloads/{0}'.format(name) + command = 'imish -r {0} -f {1}'.format(self.want.route_domain, filepath) + + params = { + "command": "run", + "utilCmdArgs": '-c "{0}"'.format(command) + } + uri = "https://{0}:{1}/mgmt/tm/util/bash".format( + self.client.provider['server'], + self.client.provider['server_port'] + ) + resp = self.client.api.post(uri, json=params) + try: + response = resp.json() + if 'commandResult' in response: + if 'Dynamic routing is not enabled' in response['commandResult']: + raise F5ModuleError(response['commandResult']) + except ValueError as ex: + raise F5ModuleError(str(ex)) + if 'code' in response and response['code'] in [400, 403]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + def read_current_from_device(self): + command = 'imish -r {0} -e \\\"show running-config\\\"'.format(self.want.route_domain) + + params = { + "command": "run", + "utilCmdArgs": '-c "{0}"'.format(command) + } + uri = "https://{0}:{1}/mgmt/tm/util/bash".format( + self.client.provider['server'], + self.client.provider['server_port'] + ) + resp = self.client.api.post(uri, json=params) + try: + response = resp.json() + if 'commandResult' in response: + if 'Dynamic routing is not enabled' in response['commandResult']: + raise F5ModuleError(response['commandResult']) + except ValueError as ex: + raise F5ModuleError(str(ex)) + if 'code' in response and response['code'] in [400, 403]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + return response['commandResult'] + + def save_on_device(self): + command = 'imish -e write' + params = { + "command": "run", + "utilCmdArgs": '-c "{0}"'.format(command) + } + uri = "https://{0}:{1}/mgmt/tm/util/bash".format( + self.client.provider['server'], + self.client.provider['server_port'] + ) + resp = self.client.api.post(uri, json=params) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + if 'code' in response and response['code'] in [400, 403]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'): + diff = {} + + # prepare candidate configuration + candidate_obj = NetworkConfig(indent=1) + candidate_obj.load(candidate) + + if running and diff_match != 'none' and diff_replace != 'config': + # running configuration + running_obj = NetworkConfig(indent=1, contents=running, ignore_lines=diff_ignore_lines) + configdiffobjs = candidate_obj.difference(running_obj, path=path, match=diff_match, replace=diff_replace) + else: + configdiffobjs = candidate_obj.items + + diff['config_diff'] = dumps(configdiffobjs, 'commands') if configdiffobjs else '' + return diff + + def get_running_config(self, config=None): + contents = self.want.running_config + if not contents: + if config: + contents = config + else: + contents = self.read_current_from_device() + return contents + + def get_candidate(self): + candidate = '' + if self.want.src: + candidate = self.want.src + + elif self.want.lines: + candidate_obj = NetworkConfig(indent=1) + parents = self.want.parents or list() + candidate_obj.add(self.want.lines, parents=parents) + candidate = dumps(candidate_obj, 'raw') + return candidate + + def execute_show_commands(self, commands): + body = [] + + uri = "https://{0}:{1}/mgmt/tm/util/bash".format( + self.client.provider['server'], + self.client.provider['server_port'] + ) + for command in to_list(commands): + command = 'imish -r {0} -e \\\"{1}\\\"'.format(self.want.route_domain, command) + params = { + "command": "run", + "utilCmdArgs": '-c "{0}"'.format(command) + } + resp = self.client.api.post(uri, json=params) + try: + response = resp.json() + if 'commandResult' in response: + if 'Dynamic routing is not enabled' in response['commandResult']: + raise F5ModuleError(response['commandResult']) + except ValueError as ex: + raise F5ModuleError(str(ex)) + if 'code' in response and response['code'] in [400, 403]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + body.append(response['commandResult']) + return body + + def save_config(self, result): + result['changed'] = True + if self.module.check_mode: + self.module.warn( + 'Skipping command `copy running-config startup-config` ' + 'due to check_mode. Configuration not copied to ' + 'non-volatile storage' + ) + return + self.save_on_device() + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + argument_spec = dict( + route_domain=dict(default=0), + src=dict(type='path'), + lines=dict(aliases=['commands'], type='list'), + parents=dict(type='list'), + + before=dict(type='list'), + after=dict(type='list'), + + match=dict(default='line', choices=['line', 'strict', 'exact', 'none']), + replace=dict(default='line', choices=['line', 'block']), + + running_config=dict(aliases=['config']), + intended_config=dict(), + + backup=dict(type='bool', default=False), + + save_when=dict(choices=['always', 'never', 'modified', 'changed'], default='never'), + + diff_against=dict(choices=['running', 'startup', 'intended'], default='startup'), + diff_ignore_lines=dict(type='list'), + ) + self.argument_spec = {} + self.argument_spec.update(f5_argument_spec) + self.argument_spec.update(argument_spec) + self.mutually_exclusive = [ + ('lines', 'src'), + ('parents', 'src'), + ] + self.required_if = [ + ('match', 'strict', ['lines']), + ('match', 'exact', ['lines']), + ('replace', 'block', ['lines']), + ('diff_against', 'intended', ['intended_config']) + ] + self.add_file_common_args = True + + +def main(): + spec = ArgumentSpec() + + module = AnsibleModule( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + mutually_exclusive=spec.mutually_exclusive, + required_if=spec.required_if, + add_file_common_args=spec.add_file_common_args, + ) + + client = F5RestClient(**module.params) + + try: + mm = ModuleManager(module=module, client=client) + results = mm.exec_module() + exit_json(module, results, client) + except F5ModuleError as ex: + fail_json(module, ex, client) + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/plugins/action/bigip_imish_config.py b/lib/ansible/plugins/action/bigip_imish_config.py new file mode 100644 index 0000000000..d0a8741f9a --- /dev/null +++ b/lib/ansible/plugins/action/bigip_imish_config.py @@ -0,0 +1,122 @@ +# +# (c) 2017, 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 re +import time +import glob + +from ansible.module_utils._text import to_text +from ansible.plugins.action.bigip import ActionModule as _ActionModule +from ansible.module_utils.six.moves.urllib.parse import urlsplit + +try: + from library.module_utils.network.f5.common import f5_provider_spec +except: + from ansible.module_utils.network.f5.common import f5_provider_spec + +from ansible.utils.display import Display +display = Display() + + +PRIVATE_KEYS_RE = re.compile('__.+__') + + +class ActionModule(_ActionModule): + + def run(self, tmp=None, task_vars=None): + if self._task.args.get('src'): + try: + self._handle_template() + except ValueError as exc: + return dict(failed=True, msg=to_text(exc)) + + result = super(ActionModule, self).run(tmp, task_vars) + del tmp # tmp no longer has any effect + + if self._task.args.get('backup') and result.get('__backup__'): + # User requested backup and no error occurred in module. + # NOTE: If there is a parameter error, _backup key may not be in results. + filepath = self._write_backup(task_vars['inventory_hostname'], + result['__backup__']) + + result['backup_path'] = filepath + + # strip out any keys that have two leading and two trailing + # underscore characters + for key in list(result.keys()): + if PRIVATE_KEYS_RE.match(key): + del result[key] + + return result + + def _get_working_path(self): + cwd = self._loader.get_basedir() + if self._task._role is not None: + cwd = self._task._role._role_path + return cwd + + def _write_backup(self, host, contents): + backup_path = self._get_working_path() + '/backup' + if not os.path.exists(backup_path): + os.mkdir(backup_path) + for fn in glob.glob('%s/%s*' % (backup_path, host)): + os.remove(fn) + tstamp = time.strftime("%Y-%m-%d@%H:%M:%S", time.localtime(time.time())) + filename = '%s/%s_config.%s' % (backup_path, host, tstamp) + fh = open(filename, 'w') + fh.write(contents) + fh.close() + return filename + + def _handle_template(self): + src = self._task.args.get('src') + working_path = self._get_working_path() + + if os.path.isabs(src) or urlsplit('src').scheme: + source = src + else: + source = self._loader.path_dwim_relative(working_path, 'templates', src) + if not source: + source = self._loader.path_dwim_relative(working_path, src) + + if not os.path.exists(source): + raise ValueError('path specified in src not found') + + try: + with open(source, 'r') as f: + template_data = to_text(f.read()) + except IOError: + return dict(failed=True, msg='unable to load src file') + + # Create a template search path in the following order: + # [working_path, self_role_path, dependent_role_paths, dirname(source)] + searchpath = [working_path] + if self._task._role is not None: + searchpath.append(self._task._role._role_path) + if hasattr(self._task, "_block:"): + dep_chain = self._task._block.get_dep_chain() + if dep_chain is not None: + for role in dep_chain: + searchpath.append(role._role_path) + searchpath.append(os.path.dirname(source)) + self._templar.environment.loader.searchpath = searchpath + self._task.args['src'] = self._templar.template(template_data) diff --git a/test/units/modules/network/f5/fixtures/load_imish_output_1.json b/test/units/modules/network/f5/fixtures/load_imish_output_1.json new file mode 100644 index 0000000000..a2b72a2a7a --- /dev/null +++ b/test/units/modules/network/f5/fixtures/load_imish_output_1.json @@ -0,0 +1,6 @@ +{ + "kind": "tm:util:bash:runstate", + "command": "run", + "utilCmdArgs": "-c 'imish -r 0 -e \"show running-config\"'", + "commandResult": "!\nno service password-encryption\n!\nline con 0\n login\nline vty 0 39\n login\n!\nend\n\n" +} diff --git a/test/units/modules/network/f5/test_bigip_imish_config.py b/test/units/modules/network/f5/test_bigip_imish_config.py new file mode 100644 index 0000000000..cf23b8883c --- /dev/null +++ b/test/units/modules/network/f5/test_bigip_imish_config.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2018, 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 sys + +from nose.plugins.skip import SkipTest +if sys.version_info < (2, 7): + raise SkipTest("F5 Ansible modules require Python >= 2.7") + +from ansible.module_utils.basic import AnsibleModule + +try: + from library.modules.bigip_imish_config import ApiParameters + from library.modules.bigip_imish_config import ModuleParameters + from library.modules.bigip_imish_config import ModuleManager + from library.modules.bigip_imish_config import ArgumentSpec + + # In Ansible 2.8, Ansible changed import paths. + from test.units.compat import unittest + from test.units.compat.mock import Mock + from test.units.compat.mock import patch + + from test.units.modules.utils import set_module_args +except ImportError: + try: + from ansible.modules.network.f5.bigip_imish_config import ApiParameters + from ansible.modules.network.f5.bigip_imish_config import ModuleParameters + from ansible.modules.network.f5.bigip_imish_config import ModuleManager + from ansible.modules.network.f5.bigip_imish_config import ArgumentSpec + + # Ansible 2.8 imports + from units.compat import unittest + from units.compat.mock import Mock + from units.compat.mock import patch + + 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 + + +@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(self, *args): + set_module_args(dict( + lines=[ + 'bgp graceful-restart restart-time 120', + 'redistribute kernel route-map rhi', + 'neighbor 10.10.10.11 remote-as 65000', + 'neighbor 10.10.10.11 fall-over bfd', + 'neighbor 10.10.10.11 remote-as 65000', + 'neighbor 10.10.10.11 fall-over bfd' + ], + parents='router bgp 64664', + before='bfd slow-timer 2000', + match='exact', + server='localhost', + password='password', + user='admin' + )) + + current = load_fixture('load_imish_output_1.json') + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode + ) + + # Override methods in the specific type of manager + mm = ModuleManager(module=module) + mm.read_current_from_device = Mock(return_value=current['commandResult']) + mm.upload_file_to_device = Mock(return_value=True) + mm.load_config_on_device = Mock(return_value=True) + mm.remove_uploaded_file_from_device = Mock(return_value=True) + + results = mm.exec_module() + + assert results['changed'] is True