From 8e2dcaf9f64abfda3077a2cf5ead590f5f6dc91c Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Mon, 17 Jul 2017 21:23:38 -0400 Subject: [PATCH] update asa to use network_cli connection plugin (#26899) * WIP update asa to use network_cli connection plugin * add asa.py to cliconf plugins * update asa.py terminal plugin to support regexp and events * update constants to map asa modules to asa action handler * update asa action handler to implement persistent connections * update asa shared module to use persistent connections * update asa_command module to use new connection * fixed pep8 issues --- lib/ansible/config/data/config.yml | 30 ++-- lib/ansible/module_utils/asa.py | 165 +++++++++++------- .../modules/network/asa/asa_command.py | 95 ++++------ lib/ansible/plugins/action/asa.py | 111 ++++++++++++ lib/ansible/plugins/cliconf/asa.py | 78 +++++++++ lib/ansible/plugins/terminal/asa.py | 42 ++--- 6 files changed, 354 insertions(+), 167 deletions(-) create mode 100644 lib/ansible/plugins/action/asa.py create mode 100644 lib/ansible/plugins/cliconf/asa.py diff --git a/lib/ansible/config/data/config.yml b/lib/ansible/config/data/config.yml index af5f3cde258..197edc32311 100644 --- a/lib/ansible/config/data/config.yml +++ b/lib/ansible/config/data/config.yml @@ -448,7 +448,7 @@ DEFAULT_BECOME_ASK_PASS: vars: [] yaml: {key: privilege_escalation.become_ask_pass} DEFAULT_BECOME_EXE: - default: + default: desc: 'TODO: write it' env: [{name: ANSIBLE_BECOME_EXE}] ini: @@ -456,7 +456,7 @@ DEFAULT_BECOME_EXE: vars: [] yaml: {key: privilege_escalation.become_exe} DEFAULT_BECOME_FLAGS: - default: + default: desc: 'TODO: write it' env: [{name: ANSIBLE_BECOME_FLAGS}] ini: @@ -542,7 +542,7 @@ DEFAULT_EXECUTABLE: vars: [] yaml: {key: defaults.executable} DEFAULT_FACT_PATH: - default: + default: desc: 'TODO: write it' env: [{name: ANSIBLE_FACT_PATH}] ini: @@ -650,7 +650,7 @@ DEFAULT_INVENTORY_PLUGIN_PATH: vars: [] yaml: {key: defaults.inventory_plugins} DEFAULT_JINJA2_EXTENSIONS: - default: + default: desc: 'TODO: write it' env: [{name: ANSIBLE_JINJA2_EXTENSIONS}] ini: @@ -797,7 +797,7 @@ DEFAULT_NO_TARGET_SYSLOG: vars: [] yaml: {key: defaults.no_target_syslog} DEFAULT_NULL_REPRESENTATION: - default: + default: desc: 'TODO: write it' env: [{name: ANSIBLE_NULL_REPRESENTATION}] ini: @@ -815,7 +815,7 @@ DEFAULT_POLL_INTERVAL: vars: [] yaml: {key: defaults.poll_interval} DEFAULT_PRIVATE_KEY_FILE: - default: + default: desc: 'TODO: write it' env: [{name: ANSIBLE_PRIVATE_KEY_FILE}] ini: @@ -833,7 +833,7 @@ DEFAULT_PRIVATE_ROLE_VARS: vars: [] yaml: {key: defaults.private_role_vars} DEFAULT_REMOTE_PORT: - default: + default: desc: 'TODO: write it' env: [{name: ANSIBLE_REMOTE_PORT}] ini: @@ -851,7 +851,7 @@ DEFAULT_REMOTE_TMP: - name: ansible_remote_tmp yaml: {key: defaults.remote_tmp} DEFAULT_REMOTE_USER: - default: + default: desc: 'TODO: write it' env: [{name: ANSIBLE_REMOTE_USER}] ini: @@ -904,7 +904,7 @@ DEFAULT_SQUASH_ACTIONS: vars: [] yaml: {key: defaults.squash_actions} DEFAULT_SSH_TRANSFER_METHOD: - default: + default: desc: 'TODO: write it' env: [{name: ANSIBLE_SSH_TRANSFER_METHOD}] ini: @@ -955,7 +955,7 @@ DEFAULT_SUDO: vars: [] yaml: {key: defaults.sudo} DEFAULT_SUDO_EXE: - default: + default: desc: 'TODO: write it' env: [{name: ANSIBLE_SUDO_EXE}] ini: @@ -979,7 +979,7 @@ DEFAULT_SUDO_USER: vars: [] yaml: {key: defaults.sudo_user} DEFAULT_SU_EXE: - default: + default: desc: 'TODO: write it' env: [{name: ANSIBLE_SU_EXE}] ini: @@ -987,7 +987,7 @@ DEFAULT_SU_EXE: vars: [] yaml: {key: defaults.su_exe} DEFAULT_SU_FLAGS: - default: + default: desc: 'TODO: write it' env: [{name: ANSIBLE_SU_FLAGS}] ini: @@ -1154,7 +1154,7 @@ GALAXY_IGNORE_CERTS: vars: [] yaml: {key: galaxy.ignore_certs} GALAXY_ROLE_SKELETON: - default: + default: desc: 'TODO: write it' env: [{name: ANSIBLE_GALAXY_ROLE_SKELETON}] ini: @@ -1243,7 +1243,7 @@ MERGE_MULTIPLE_CLI_TAGS: vars: [] yaml: {key: defaults.merge_multiple_cli_tags} NETWORK_GROUP_MODULES: - default: [eos, nxos, ios, iosxr, junos, ce, vyos, sros, dellos9, dellos10, dellos6] + default: [eos, nxos, ios, iosxr, junos, ce, vyos, sros, dellos9, dellos10, dellos6, asa] desc: 'TODO: write it' env: [{name: NETWORK_GROUP_MODULES}] ini: @@ -1282,7 +1282,7 @@ PARAMIKO_LOOK_FOR_KEYS: vars: [] yaml: {key: paramiko_connection.look_for_keys} PARAMIKO_PROXY_COMMAND: - default: + default: desc: 'TODO: write it' env: [{name: ANSIBLE_PARAMIKO_PROXY_COMMAND}] ini: diff --git a/lib/ansible/module_utils/asa.py b/lib/ansible/module_utils/asa.py index 97d60815fdb..d4b38804833 100644 --- a/lib/ansible/module_utils/asa.py +++ b/lib/ansible/module_utils/asa.py @@ -4,8 +4,7 @@ # still belong to the author of the module, and may assign their own license # to the complete work. # -# Copyright (c) 2016 Peter Sprygada, -# Copyright (c) 2016 Patrick Ogenstad, <@ogenstad> +# (c) 2016 Red Hat Inc. # # Redistribution and use in source and binary forms, with or without modification, # are permitted provided that the following conditions are met: @@ -26,90 +25,126 @@ # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # +from ansible.module_utils._text import to_text +from ansible.module_utils.basic import env_fallback, return_values +from ansible.module_utils.network_common import to_list, EntityCollection +from ansible.module_utils.connection import Connection -import re +_DEVICE_CONFIGS = {} +_CONNECTION = None -from ansible.module_utils.network import NetworkError, NetworkModule -from ansible.module_utils.network import add_argument, register_transport -from ansible.module_utils.network import to_list -from ansible.module_utils.shell import CliBase -from ansible.module_utils.netcli import Command +asa_argument_spec = { + 'host': dict(), + 'port': dict(type='int'), + 'username': dict(fallback=(env_fallback, ['ANSIBLE_NET_USERNAME'])), + 'password': dict(fallback=(env_fallback, ['ANSIBLE_NET_PASSWORD']), no_log=True), + 'ssh_keyfile': dict(fallback=(env_fallback, ['ANSIBLE_NET_SSH_KEYFILE']), type='path'), + 'authorize': dict(fallback=(env_fallback, ['ANSIBLE_NET_AUTHORIZE']), type='bool'), + 'auth_pass': dict(fallback=(env_fallback, ['ANSIBLE_NET_AUTH_PASS']), no_log=True), + 'timeout': dict(type='int'), + 'provider': dict(type='dict'), + 'context': dict() +} -add_argument('context', dict(required=False)) +command_spec = { + 'command': dict(key=True), + 'prompt': dict(), + 'answer': dict() +} -class Cli(CliBase): +def get_argspec(): + return asa_argument_spec - CLI_PROMPTS_RE = [ - re.compile(r"[\r\n]?[\w+\-\.:\/\[\]]+(?:\([^\)]+\)){,3}(?:>|#) ?$"), - re.compile(r"\[\w+\@[\w\-\.]+(?: [^\]])\] ?[>#\$] ?$") - ] - CLI_ERRORS_RE = [ - re.compile(r"error:", re.I), - re.compile(r"^Removing.* not allowed") - ] +def check_args(module): + provider = module.params['provider'] or {} - NET_PASSWD_RE = re.compile(r"[\r\n]?password: $", re.I) + for key in asa_argument_spec: + if key not in ['provider', 'authorize'] and module.params[key]: + module.warn('argument %s has been deprecated and will be removed in a future version' % key) - def __init__(self, *args, **kwargs): + if provider: + for param in ('auth_pass', 'password'): + if provider.get(param): + module.no_log_values.update(return_values(provider[param])) - super(Cli, self).__init__(*args, **kwargs) - self.default_output = 'text' - def connect(self, params, **kwargs): - super(Cli, self).connect(params, kickstart=False, **kwargs) +def get_connection(module): + global _CONNECTION + if _CONNECTION: + return _CONNECTION + _CONNECTION = Connection(module) - if params['context']: - self.change_context(params, **kwargs) + context = module.params['context'] - def authorize(self, params, **kwargs): - passwd = params['auth_pass'] - errors = self.shell.errors - # Disable errors (if already in enable mode) - self.shell.errors = [] - cmd = Command('enable', prompt=self.NET_PASSWD_RE, response=passwd) - self.execute([cmd, 'no terminal pager']) - # Reapply error handling - self.shell.errors = errors - - def change_context(self, params): - context = params['context'] + if context: if context == 'system': command = 'changeto system' else: command = 'changeto context %s' % context + _CONNECTION.get(command) - self.execute(command) + return _CONNECTION - # Config methods - def configure(self, commands): - cmds = ['configure terminal'] - cmds.extend(to_list(commands)) - if cmds[-1] == 'exit': - cmds[-1] = 'end' - elif cmds[-1] != 'end': - cmds.append('end') - responses = self.execute(cmds) - return responses[1:] +def to_commands(module, commands): + assert isinstance(commands, list), 'argument must be of type ' - def get_config(self, include=None): - if include not in [None, 'defaults', 'passwords']: - raise ValueError('include must be one of None, defaults, passwords') - cmd = 'show running-config' - if include == 'passwords': - cmd = 'more system:running-config' - elif include == 'defaults': - cmd = 'show running-config all' - else: - cmd = 'show running-config' - return self.run_commands(cmd)[0] + transform = EntityCollection(module, command_spec) + commands = transform(commands) - def load_config(self, commands): - return self.configure(commands) + for index, item in enumerate(commands): + if module.check_mode and not item['command'].startswith('show'): + module.warn('only show commands are supported when using check ' + 'mode, not executing `%s`' % item['command']) - def save_config(self): - self.execute(['write memory']) + return commands -Cli = register_transport('cli', default=True)(Cli) + +def run_commands(module, commands, check_rc=True): + commands = to_commands(module, to_list(commands)) + connection = get_connection(module) + + responses = list() + + for cmd in commands: + out = connection.get(**cmd) + responses.append(to_text(out, errors='surrogate_then_replace')) + + return responses + + +def get_config(module, flags=[]): + cmd = 'show running-config ' + cmd += ' '.join(flags) + cmd = cmd.strip() + + try: + return _DEVICE_CONFIGS[cmd] + except KeyError: + conn = get_connection(module) + out = conn.get(cmd) + cfg = to_text(out, errors='surrogate_then_replace').strip() + _DEVICE_CONFIGS[cmd] = cfg + return cfg + + +def load_config(module, config): + conn = get_connection(module) + conn.edit_config(config) + + +def get_defaults_flag(module): + rc, out, err = exec_command(module, 'show running-config ?') + out = to_text(out, errors='surrogate_then_replace') + + commands = set() + for line in out.splitlines(): + if line: + commands.add(line.strip().split()[0]) + + if 'all' in commands: + return 'all' + else: + return 'full' diff --git a/lib/ansible/modules/network/asa/asa_command.py b/lib/ansible/modules/network/asa/asa_command.py index c28df85d289..5e927cf2c67 100644 --- a/lib/ansible/modules/network/asa/asa_command.py +++ b/lib/ansible/modules/network/asa/asa_command.py @@ -133,28 +133,18 @@ failed_conditions: type: list sample: ['...', '...'] """ -from ansible.module_utils.basic import get_exception -from ansible.module_utils.netcli import CommandRunner -from ansible.module_utils.netcli import AddCommandError, FailedConditionsError -from ansible.module_utils.asa import NetworkModule, NetworkError +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.asa import asa_argument_spec, check_args +from ansible.module_utils.asa import run_commands +from ansible.module_utils.six import string_types -VALID_KEYS = ['command', 'prompt', 'response'] def to_lines(stdout): for item in stdout: - if isinstance(item, basestring): + if isinstance(item, string_types): item = str(item).split('\n') yield item -def parse_commands(module): - for cmd in module.params['commands']: - if isinstance(cmd, basestring): - cmd = dict(command=cmd, output=None) - elif 'command' not in cmd: - module.fail_json(msg='command keyword argument is required') - elif not set(cmd.keys()).issubset(VALID_KEYS): - module.fail_json(msg='unknown keyword specified') - yield cmd def main(): spec = dict( @@ -168,59 +158,48 @@ def main(): interval=dict(default=1, type='int') ) - module = NetworkModule(argument_spec=spec, - connect_on_load=False, - supports_check_mode=True) + spec.update(asa_argument_spec) - commands = list(parse_commands(module)) - conditionals = module.params['wait_for'] or list() + module = AnsibleModule(argument_spec=spec, supports_check_mode=True) + check_args(module) - warnings = list() + result = {'changed': False} - runner = CommandRunner(module) + wait_for = module.params['wait_for'] or list() + conditionals = [Conditional(c) for c in wait_for] - for cmd in commands: - if module.check_mode and not cmd['command'].startswith('show'): - warnings.append('only show commands are supported when using ' - 'check mode, not executing `%s`' % cmd['command']) - else: - if cmd['command'].startswith('conf'): - module.fail_json(msg='asa_command does not support running ' - 'config mode commands. Please use ' - 'asa_config instead') - try: - runner.add_command(**cmd) - except AddCommandError: - exc = get_exception() - warnings.append('duplicate command detected: %s' % cmd) + commands = module.params['commands'] + retries = module.params['retries'] + interval = module.params['interval'] + match = module.params['match'] - for item in conditionals: - runner.add_conditional(item) + while retries > 0: + responses = run_commands(module, commands) - runner.retries = module.params['retries'] - runner.interval = module.params['interval'] - runner.match = module.params['match'] + for item in list(conditionals): + if item(responses): + if match == 'any': + conditionals = list() + break + conditionals.remove(item) - try: - runner.run() - except FailedConditionsError: - exc = get_exception() - module.fail_json(msg=str(exc), failed_conditions=exc.failed_conditions) - except NetworkError: - exc = get_exception() - module.fail_json(msg=str(exc)) + if not conditionals: + break - result = dict(changed=False, stdout=list()) + time.sleep(interval) + retries -= 1 - for cmd in commands: - try: - output = runner.get_command(cmd['command']) - except ValueError: - output = 'command not executed due to check_mode, see warnings' - result['stdout'].append(output) + if conditionals: + failed_conditions = [item.raw for item in conditionals] + msg = 'One or more conditional statements have not be satisfied' + module.fail_json(msg=msg, failed_conditions=failed_conditions) - result['warnings'] = warnings - result['stdout_lines'] = list(to_lines(result['stdout'])) + + result.update({ + 'changed': False, + 'stdout': responses, + 'stdout_lines': list(to_lines(responses)) + }) module.exit_json(**result) diff --git a/lib/ansible/plugins/action/asa.py b/lib/ansible/plugins/action/asa.py new file mode 100644 index 00000000000..3a9eff55792 --- /dev/null +++ b/lib/ansible/plugins/action/asa.py @@ -0,0 +1,111 @@ +# +# (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 +import json + +from ansible.plugins.action.normal import ActionModule as _ActionModule +from ansible.module_utils.basic import AnsibleFallbackNotFound +from ansible.module_utils.asa import asa_argument_spec +from ansible.module_utils.six import iteritems +from ansible.module_utils.connection import request_builder + +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): + + if self._play_context.connection != 'local': + return dict( + failed=True, + msg='invalid connection specified, expected connection=local, ' + 'got %s' % self._play_context.connection + ) + + provider = self.load_provider() + + pc = copy.deepcopy(self._play_context) + pc.connection = 'network_cli' + pc.network_os = 'asa' + pc.remote_addr = provider['host'] or self._play_context.remote_addr + pc.port = provider['port'] or self._play_context.port or 22 + pc.remote_user = provider['username'] or self._play_context.connection_user + pc.password = provider['password'] or self._play_context.password + pc.private_key_file = provider['ssh_keyfile'] or self._play_context.private_key_file + pc.timeout = provider['timeout'] or self._play_context.timeout + pc.become = provider['authorize'] or False + pc.become_pass = provider['auth_pass'] + + 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 + + result = super(ActionModule, self).run(tmp, task_vars) + + # take the shell out of enable mode + if pc.become: + req = json.dumps(request_builder('get', 'disable')) + out = connection.exec_command(req) + + return result + + def load_provider(self): + provider = self._task.args.get('provider', {}) + for key, value in iteritems(asa_argument_spec): + if key != 'provider' and key not in provider: + if key in self._task.args: + provider[key] = self._task.args[key] + elif 'fallback' in value: + provider[key] = self._fallback(value['fallback']) + elif key not in provider: + provider[key] = None + return provider + + def _fallback(self, fallback): + strategy = fallback[0] + args = [] + kwargs = {} + + for item in fallback[1:]: + if isinstance(item, dict): + kwargs = item + else: + args = item + try: + return strategy(*args, **kwargs) + except AnsibleFallbackNotFound: + pass diff --git a/lib/ansible/plugins/cliconf/asa.py b/lib/ansible/plugins/cliconf/asa.py new file mode 100644 index 00000000000..fde32c69a2c --- /dev/null +++ b/lib/ansible/plugins/cliconf/asa.py @@ -0,0 +1,78 @@ +# +# (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 re +import json + +from itertools import chain + +from ansible.module_utils._text import to_bytes, to_text +from ansible.module_utils.network_common import to_list +from ansible.plugins.cliconf import CliconfBase, enable_mode + + +class Cliconf(CliconfBase): + + def get_device_info(self): + device_info = {} + + device_info['network_os'] = 'asa' + reply = self.get(b'show version') + data = to_text(reply, errors='surrogate_or_strict').strip() + + match = re.search(r'Version (\S+),', data) + if match: + device_info['network_os_version'] = match.group(1) + + match = re.search(r'^Model Id:\s+(.+) \(revision', data, re.M) + if match: + device_info['network_os_model'] = match.group(1) + + match = re.search(r'^(.+) up', data, re.M) + if match: + device_info['network_os_hostname'] = match.group(1) + + return device_info + + @enable_mode + def get_config(self, source='running'): + if source not in ('running', 'startup'): + return self.invalid_params("fetching configuration from %s is not supported" % source) + if source == 'running': + cmd = b'show running-config all' + else: + cmd = b'show startup-config' + return self.send_command(cmd) + + @enable_mode + def edit_config(self, command): + for cmd in chain([b'configure terminal'], to_list(command), [b'end']): + self.send_command(cmd) + + def get(self, *args, **kwargs): + return self.send_command(*args, **kwargs) + + def get_capabilities(self): + result = {} + result['rpc'] = self.get_base_rpc() + result['network_api'] = 'cliconf' + result['device_info'] = self.get_device_info() + return json.dumps(result) diff --git a/lib/ansible/plugins/terminal/asa.py b/lib/ansible/plugins/terminal/asa.py index 9fc117804bf..2186900f152 100644 --- a/lib/ansible/plugins/terminal/asa.py +++ b/lib/ansible/plugins/terminal/asa.py @@ -22,8 +22,9 @@ __metaclass__ = type import re import json -from ansible.plugins.terminal import TerminalBase from ansible.errors import AnsibleConnectionFailure +from ansible.module_utils._text import to_text, to_bytes +from ansible.plugins.terminal import TerminalBase class TerminalModule(TerminalBase): @@ -34,40 +35,23 @@ class TerminalModule(TerminalBase): ] terminal_stderr_re = [ - re.compile(r"% ?Error"), - re.compile(r"^% \w+", re.M), - re.compile(r"% ?Bad secret"), - re.compile(r"invalid input", re.I), - re.compile(r"(?:incomplete|ambiguous) command", re.I), - re.compile(r"connection timed out", re.I), - re.compile(r"[^\r\n]+ not found", re.I), - re.compile(r"'[^']' +returned error code: ?\d+"), + re.compile(r"error:", re.I), + re.compile(r"^Removing.* not allowed") ] - def authorize(self, passwd=None): - if self._get_prompt().endswith('#'): + def on_authorize(self, passwd=None): + if self._get_prompt().endswith(b'#'): return - cmd = {'command': 'enable'} + cmd = {u'command': u'enable'} if passwd: - cmd['prompt'] = r"[\r\n]?password: $" - cmd['answer'] = passwd + # Note: python-3.5 cannot combine u"" and r"" together. Thus make + # an r string and use to_text to ensure it's text on both py2 and py3. + cmd[u'prompt'] = to_text(r"[\r\n]?password: $", errors='surrogate_or_strict') + cmd[u'answer'] = passwd try: - self._exec_cli_command(json.dumps(cmd)) - self._exec_cli_command('terminal pager 0') + self._exec_cli_command(to_bytes(json.dumps(cmd), errors='surrogate_or_strict')) + self._exec_cli_command(u'no terminal pager') except AnsibleConnectionFailure: raise AnsibleConnectionFailure('unable to elevate privilege to enable mode') - - def on_deauthorize(self): - prompt = self._get_prompt() - if prompt is None: - # if prompt is None most likely the terminal is hung up at a prompt - return - - if '(config' in prompt: - self._exec_cli_command('end') - self._exec_cli_command('disable') - - elif prompt.endswith('#'): - self._exec_cli_command('disable')