From ab39481c3152f8a6886c4d1f3c43ac025b5a7a2c Mon Sep 17 00:00:00 2001 From: Nathaniel Case Date: Mon, 6 Aug 2018 10:41:57 -0400 Subject: [PATCH] cli_command module (#42916) * Create cli_command module to do direct cliconf calls * Update edgeos cliconf signature to match. * cli_command is cli-only * Add tests --- lib/ansible/modules/network/cli/__init__.py | 0 .../modules/network/cli/cli_command.py | 230 ++++++++++++++++++ lib/ansible/plugins/action/cli_command.py | 31 +++ lib/ansible/plugins/cliconf/edgeos.py | 6 +- lib/ansible/plugins/cliconf/iosxr.py | 2 +- .../eos_smoke/tests/cli/cli_command.yaml | 42 ++++ .../eos_smoke/tests/cli/misc_tests.yaml | 2 +- .../ios_smoke/tests/cli/cli_command.yaml | 32 +++ .../ios_smoke/tests/cli/misc_tests.yaml | 2 +- .../iosxr_smoke/tests/cli/cli_command.yaml | 32 +++ .../targets/junos_smoke/tasks/cli.yaml | 16 ++ .../targets/junos_smoke/tasks/main.yaml | 1 + .../junos_smoke/tests/cli/cli_commmand.yaml | 29 +++ .../nxos_smoke/tests/cli/cli_command.yaml | 32 +++ .../vyos_smoke/tests/cli/cli_command.yaml | 29 +++ 15 files changed, 480 insertions(+), 6 deletions(-) create mode 100644 lib/ansible/modules/network/cli/__init__.py create mode 100644 lib/ansible/modules/network/cli/cli_command.py create mode 100644 lib/ansible/plugins/action/cli_command.py create mode 100644 test/integration/targets/eos_smoke/tests/cli/cli_command.yaml create mode 100644 test/integration/targets/ios_smoke/tests/cli/cli_command.yaml create mode 100644 test/integration/targets/iosxr_smoke/tests/cli/cli_command.yaml create mode 100644 test/integration/targets/junos_smoke/tasks/cli.yaml create mode 100644 test/integration/targets/junos_smoke/tests/cli/cli_commmand.yaml create mode 100644 test/integration/targets/nxos_smoke/tests/cli/cli_command.yaml create mode 100644 test/integration/targets/vyos_smoke/tests/cli/cli_command.yaml diff --git a/lib/ansible/modules/network/cli/__init__.py b/lib/ansible/modules/network/cli/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lib/ansible/modules/network/cli/cli_command.py b/lib/ansible/modules/network/cli/cli_command.py new file mode 100644 index 00000000000..ec5fe1c08ea --- /dev/null +++ b/lib/ansible/modules/network/cli/cli_command.py @@ -0,0 +1,230 @@ +#!/usr/bin/python +# Copyright: Ansible Project +# 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': 'network'} + + +DOCUMENTATION = """ +--- +module: cli_command +version_added: "2.7" +author: "Nathaniel Case (@qalthos)" +short_description: Run arbitrary commands on cli-based network devices +description: + - Sends an arbitrary set of commands to a network device and returns the + results read from the device. This module includes an argument that + will cause the module to wait for a specific condition before returning + or timing out if the condition is not met. +notes: + - Tested against EOS 4.15 +options: + commands: + description: + - The commands to send to the remote EOS device over the + configured provider. The resulting output from the command + is returned. If the I(wait_for) argument is provided, the + module is not returned until the condition is satisfied or + the number of I(retries) has been exceeded. + required: true + wait_for: + description: + - Specifies what to evaluate from the output of the command + and what conditionals to apply. This argument will cause + the task to wait for a particular conditional to be true + before moving forward. If the conditional is not true + by the configured retries, the task fails. + Note - With I(wait_for) the value in C(result['stdout']) can be accessed + using C(result), that is to access C(result['stdout'][0]) use C(result[0]) See examples. + aliases: ['waitfor'] + version_added: "2.2" + match: + description: + - The I(match) argument is used in conjunction with the + I(wait_for) argument to specify the match policy. Valid + values are C(all) or C(any). If the value is set to C(all) + then all conditionals in the I(wait_for) must be satisfied. If + the value is set to C(any) then only one of the values must be + satisfied. + default: all + choices: ['any', 'all'] + version_added: "2.2" + retries: + description: + - Specifies the number of retries a command should be tried + before it is considered failed. The command is run on the + target device every retry and evaluated against the I(wait_for) + conditionals. + default: 10 + interval: + description: + - Configures the interval in seconds to wait between retries + of the command. If the command does not pass the specified + conditional, the interval indicates how to long to wait before + trying the command again. + default: 1 +""" + +EXAMPLES = """ +- name: run show version on remote devices + cli_command: + commands: show version + +- name: run show version and check to see if output contains Arista + cli_command: + commands: show version + wait_for: result[0] contains Arista + +- name: run multiple commands on remote nodes + cli_command: + commands: + - show version + - show interfaces + +- name: run multiple commands and evaluate the output + cli_command: + commands: + - show version + - show interfaces + wait_for: + - result[0] contains Arista + - result[1] contains Loopback0 + +- name: run commands and specify the output format + cli_command: + commands: + - command: show version + output: json +""" + +RETURN = """ +stdout: + description: The set of responses from the commands + returned: always apart from low level errors (such as action plugin) + type: list + sample: ['...', '...'] +stdout_lines: + description: The value of stdout split into a list + returned: always apart from low level errors (such as action plugin) + type: list + sample: [['...', '...'], ['...'], ['...']] +failed_conditions: + description: The list of conditionals that have failed + returned: failed + type: list + sample: ['...', '...'] +""" +import time + +from ansible.module_utils._text import to_text +from ansible.module_utils.six import string_types +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.connection import Connection, ConnectionError +from ansible.module_utils.network.common.parsing import Conditional +from ansible.module_utils.network.common.utils import ComplexList + +VALID_KEYS = ['command', 'output', 'prompt', 'response'] + + +def to_lines(output): + lines = [] + for item in output: + if isinstance(item, string_types): + item = to_text(item).split('\n') + lines.append(item) + return lines + + +def parse_commands(module, warnings): + transform = ComplexList(dict( + command=dict(key=True), + output=dict(), + prompt=dict(), + answer=dict() + ), module) + + commands = transform(module.params['commands']) + + if module.check_mode: + for item in list(commands): + if not item['command'].startswith('show'): + warnings.append( + 'Only show commands are supported when using check_mode, not ' + 'executing %s' % item['command'] + ) + commands.remove(item) + + return commands + + +def main(): + """entry point for module execution + """ + argument_spec = dict( + commands=dict(type='list', required=True), + + wait_for=dict(type='list', aliases=['waitfor']), + match=dict(default='all', choices=['all', 'any']), + + retries=dict(default=10, type='int'), + interval=dict(default=1, type='int') + ) + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True) + + warnings = list() + result = {'changed': False, 'warnings': warnings} + + wait_for = module.params['wait_for'] or list() + try: + conditionals = [Conditional(c) for c in wait_for] + except AttributeError as exc: + module.fail_json(msg=to_text(exc)) + + commands = parse_commands(module, warnings) + retries = module.params['retries'] + interval = module.params['interval'] + match = module.params['match'] + + connection = Connection(module._socket_path) + for attempt in range(retries): + responses = [] + try: + for command in commands: + responses.append(connection.get(**command)) + except ConnectionError as exc: + module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) + + for item in list(conditionals): + if item(responses): + if match == 'any': + conditionals = list() + break + conditionals.remove(item) + + if not conditionals: + break + + time.sleep(interval) + + if conditionals: + failed_conditions = [item.raw for item in conditionals] + msg = 'One or more conditional statements have not been satisfied' + module.fail_json(msg=msg, failed_conditions=failed_conditions) + + result.update({ + 'stdout': responses, + 'stdout_lines': to_lines(responses) + }) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/plugins/action/cli_command.py b/lib/ansible/plugins/action/cli_command.py new file mode 100644 index 00000000000..ff191f56212 --- /dev/null +++ b/lib/ansible/plugins/action/cli_command.py @@ -0,0 +1,31 @@ +# +# Copyright 2018 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 + +from ansible.plugins.action.normal import ActionModule as _ActionModule + + +class ActionModule(_ActionModule): + + def run(self, tmp=None, task_vars=None): + if self._play_context.connection != 'network_cli': + return {'failed': True, 'msg': 'Connection type %s is not valid for this module' % self._play_context.connection} + + return super(ActionModule, self).run(task_vars=task_vars) diff --git a/lib/ansible/plugins/cliconf/edgeos.py b/lib/ansible/plugins/cliconf/edgeos.py index c623d925508..0057fb2fee3 100644 --- a/lib/ansible/plugins/cliconf/edgeos.py +++ b/lib/ansible/plugins/cliconf/edgeos.py @@ -41,11 +41,11 @@ class Cliconf(CliconfBase): def get_config(self, source='running', format='text'): return self.send_command('show configuration commands') - def edit_config(self, command): - for cmd in chain(['configure'], to_list(command)): + def edit_config(self, candidate=None, commit=True, replace=False, comment=None): + for cmd in chain(['configure'], to_list(candidate)): self.send_command(cmd) - def get(self, command, prompt=None, answer=None, sendonly=False): + def get(self, command=None, prompt=None, answer=None, sendonly=False, output=None): return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly) def commit(self, comment=None): diff --git a/lib/ansible/plugins/cliconf/iosxr.py b/lib/ansible/plugins/cliconf/iosxr.py index 61d1f899e44..ac06528c6e9 100644 --- a/lib/ansible/plugins/cliconf/iosxr.py +++ b/lib/ansible/plugins/cliconf/iosxr.py @@ -86,7 +86,7 @@ class Cliconf(CliconfBase): self.send_command(command=command, prompt=prompt, answer=answer, sendonly=sendonly, newline=newline) - def get(self, command=None, prompt=None, answer=None, sendonly=False, newline=True): + def get(self, command=None, prompt=None, answer=None, sendonly=False, newline=True, output=None): return self.send_command(command=command, prompt=prompt, answer=answer, sendonly=sendonly, newline=newline) def commit(self, comment=None, label=None): diff --git a/test/integration/targets/eos_smoke/tests/cli/cli_command.yaml b/test/integration/targets/eos_smoke/tests/cli/cli_command.yaml new file mode 100644 index 00000000000..d8d42882b68 --- /dev/null +++ b/test/integration/targets/eos_smoke/tests/cli/cli_command.yaml @@ -0,0 +1,42 @@ +--- +- debug: + msg: "START cli/cli_command.yaml on connection={{ ansible_connection }}" + +- name: command that does require become (should fail) + cli_command: + commands: + - show running-config + become: no + ignore_errors: yes + register: result + +- assert: + that: + - 'result.failed == true' + - '"privileged mode required" in result.msg' + +- name: get output for single command + cli_command: + commands: + - show version + register: result + +- assert: + that: + - "result.changed == false" + - "result.stdout is defined" + +- name: get output for multiple commands + cli_command: + commands: + - show version + - show interfaces + register: result + +- assert: + that: + - "result.changed == false" + - "result.stdout is defined" + - "result.stdout | length == 2" + +- debug: msg="END cli/cli_command.yaml on connection={{ ansible_connection }}" diff --git a/test/integration/targets/eos_smoke/tests/cli/misc_tests.yaml b/test/integration/targets/eos_smoke/tests/cli/misc_tests.yaml index c38e194df6f..a11703589dc 100644 --- a/test/integration/targets/eos_smoke/tests/cli/misc_tests.yaml +++ b/test/integration/targets/eos_smoke/tests/cli/misc_tests.yaml @@ -15,7 +15,7 @@ - assert: that: - 'result.failed == true' - - '"privileged mode required" in result.module_stderr' + - '"privileged mode required" in result.msg' - name: command that doesn't require become eos_command: diff --git a/test/integration/targets/ios_smoke/tests/cli/cli_command.yaml b/test/integration/targets/ios_smoke/tests/cli/cli_command.yaml new file mode 100644 index 00000000000..e31c151229a --- /dev/null +++ b/test/integration/targets/ios_smoke/tests/cli/cli_command.yaml @@ -0,0 +1,32 @@ +--- +- debug: + msg: "START cli/cli_command.yaml on connection={{ ansible_connection }}" + +- block: + - name: get output for single command + cli_command: + commands: + - show version + register: result + + - assert: + that: + - "result.changed == false" + - "result.stdout is defined" + + - name: get output for multiple commands + cli_command: + commands: + - show version + - show interfaces + register: result + + - assert: + that: + - "result.changed == false" + - "result.stdout is defined" + - "result.stdout | length == 2" + + when: ansible_connection == 'network_cli' + +- debug: msg="END cli/cli_command.yaml on connection={{ ansible_connection }}" diff --git a/test/integration/targets/ios_smoke/tests/cli/misc_tests.yaml b/test/integration/targets/ios_smoke/tests/cli/misc_tests.yaml index e9a116ed0f6..a5918193322 100644 --- a/test/integration/targets/ios_smoke/tests/cli/misc_tests.yaml +++ b/test/integration/targets/ios_smoke/tests/cli/misc_tests.yaml @@ -34,7 +34,7 @@ - assert: that: - 'result.failed == true' - - "'timeout trying to send command' in result.module_stderr" + - "'timeout trying to send command' in result.msg" when: ansible_connection == 'network_cli' - debug: msg="END ios_smoke cli/misc_tests.yaml on connection={{ ansible_connection }}" diff --git a/test/integration/targets/iosxr_smoke/tests/cli/cli_command.yaml b/test/integration/targets/iosxr_smoke/tests/cli/cli_command.yaml new file mode 100644 index 00000000000..e31c151229a --- /dev/null +++ b/test/integration/targets/iosxr_smoke/tests/cli/cli_command.yaml @@ -0,0 +1,32 @@ +--- +- debug: + msg: "START cli/cli_command.yaml on connection={{ ansible_connection }}" + +- block: + - name: get output for single command + cli_command: + commands: + - show version + register: result + + - assert: + that: + - "result.changed == false" + - "result.stdout is defined" + + - name: get output for multiple commands + cli_command: + commands: + - show version + - show interfaces + register: result + + - assert: + that: + - "result.changed == false" + - "result.stdout is defined" + - "result.stdout | length == 2" + + when: ansible_connection == 'network_cli' + +- debug: msg="END cli/cli_command.yaml on connection={{ ansible_connection }}" diff --git a/test/integration/targets/junos_smoke/tasks/cli.yaml b/test/integration/targets/junos_smoke/tasks/cli.yaml new file mode 100644 index 00000000000..3f93a4f3696 --- /dev/null +++ b/test/integration/targets/junos_smoke/tasks/cli.yaml @@ -0,0 +1,16 @@ +--- +- name: collect all cli test cases + find: + paths: "{{ role_path }}/tests/cli" + patterns: "{{ testcase }}.yaml" + register: test_cases + delegate_to: localhost + +- name: set test_items + set_fact: test_items="{{ test_cases.files | map(attribute='path') | list }}" + +- name: run test case (connection=network_cli) + include: "{{ test_case_to_run }} ansible_connection=network_cli" + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/test/integration/targets/junos_smoke/tasks/main.yaml b/test/integration/targets/junos_smoke/tasks/main.yaml index cc27f174fd8..f0d6ea992f6 100644 --- a/test/integration/targets/junos_smoke/tasks/main.yaml +++ b/test/integration/targets/junos_smoke/tasks/main.yaml @@ -1,2 +1,3 @@ --- - { include: netconf.yaml, tags: ['netconf'] } +- { include: cli.yaml, tags: ['cli'] } diff --git a/test/integration/targets/junos_smoke/tests/cli/cli_commmand.yaml b/test/integration/targets/junos_smoke/tests/cli/cli_commmand.yaml new file mode 100644 index 00000000000..b23b81c4627 --- /dev/null +++ b/test/integration/targets/junos_smoke/tests/cli/cli_commmand.yaml @@ -0,0 +1,29 @@ +--- +- debug: + msg: "START cli/cli_command.yaml on connection={{ ansible_connection }}" + +- name: get output for single command + cli_command: + commands: + - show version + register: result + +- assert: + that: + - "result.changed == false" + - "result.stdout is defined" + +- name: get output for multiple commands + cli_command: + commands: + - show version + - show interfaces + register: result + +- assert: + that: + - "result.changed == false" + - "result.stdout is defined" + - "result.stdout | length == 2" + +- debug: msg="END cli/cli_command.yaml on connection={{ ansible_connection }}" diff --git a/test/integration/targets/nxos_smoke/tests/cli/cli_command.yaml b/test/integration/targets/nxos_smoke/tests/cli/cli_command.yaml new file mode 100644 index 00000000000..f9c10fad877 --- /dev/null +++ b/test/integration/targets/nxos_smoke/tests/cli/cli_command.yaml @@ -0,0 +1,32 @@ +--- +- debug: + msg: "START cli/cli_command.yaml on connection={{ ansible_connection }}" + +- block: + - name: get output for single command + cli_command: + commands: + - show version + register: result + + - assert: + that: + - "result.changed == false" + - "result.stdout is defined" + + - name: get output for multiple commands + cli_command: + commands: + - show version + - show interface + register: result + + - assert: + that: + - "result.changed == false" + - "result.stdout is defined" + - "result.stdout | length == 2" + + when: ansible_connection == 'network_cli' + +- debug: msg="END cli/cli_command.yaml on connection={{ ansible_connection }}" diff --git a/test/integration/targets/vyos_smoke/tests/cli/cli_command.yaml b/test/integration/targets/vyos_smoke/tests/cli/cli_command.yaml new file mode 100644 index 00000000000..b23b81c4627 --- /dev/null +++ b/test/integration/targets/vyos_smoke/tests/cli/cli_command.yaml @@ -0,0 +1,29 @@ +--- +- debug: + msg: "START cli/cli_command.yaml on connection={{ ansible_connection }}" + +- name: get output for single command + cli_command: + commands: + - show version + register: result + +- assert: + that: + - "result.changed == false" + - "result.stdout is defined" + +- name: get output for multiple commands + cli_command: + commands: + - show version + - show interfaces + register: result + +- assert: + that: + - "result.changed == false" + - "result.stdout is defined" + - "result.stdout | length == 2" + +- debug: msg="END cli/cli_command.yaml on connection={{ ansible_connection }}"