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
This commit is contained in:
Peter Sprygada 2017-07-17 21:23:38 -04:00 committed by GitHub
parent e976f299f8
commit 8e2dcaf9f6
6 changed files with 354 additions and 167 deletions

View file

@ -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:

View file

@ -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, <psprygada@ansible.com>
# 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 <list>'
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'

View file

@ -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)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
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

View file

@ -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 <http://www.gnu.org/licenses/>.
#
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)

View file

@ -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')