From 2a5a2773c8c145a167c90156d573cf8a1fa0c7f1 Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Mon, 19 Dec 2016 11:21:37 -0500 Subject: adds new module net_command for network devices (#19468) * new module net_command for sending a command to a network device * adds unit test cases for module * only works with connection=network_cli --- lib/ansible/constants.py | 4 +- lib/ansible/modules/network/basics/net_command.py | 131 +++++++++++++++++++++ lib/ansible/parsing/mod_args.py | 5 +- test/units/modules/network/__init__.py | 0 test/units/modules/network/basics/__init__.py | 0 .../modules/network/basics/test_net_command.py | 108 +++++++++++++++++ 6 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 lib/ansible/modules/network/basics/net_command.py create mode 100644 test/units/modules/network/__init__.py create mode 100644 test/units/modules/network/basics/__init__.py create mode 100644 test/units/modules/network/basics/test_net_command.py diff --git a/lib/ansible/constants.py b/lib/ansible/constants.py index 055bfd62a3..1e2a635597 100644 --- a/lib/ansible/constants.py +++ b/lib/ansible/constants.py @@ -373,8 +373,8 @@ COLOR_DIFF_LINES = get_config(p, 'colors', 'diff_lines', 'ANSIBLE_COLOR_DIFF_LI DIFF_CONTEXT = get_config(p, 'diff', 'context', 'ANSIBLE_DIFF_CONTEXT', 3, value_type='integer') # non-configurable things -MODULE_REQUIRE_ARGS = ['command', 'win_command', 'shell', 'win_shell', 'raw', 'script'] -MODULE_NO_JSON = ['command', 'win_command', 'shell', 'win_shell', 'raw'] +MODULE_REQUIRE_ARGS = ['command', 'win_command', 'net_command', 'shell', 'win_shell', 'raw', 'script'] +MODULE_NO_JSON = ['command', 'win_command', 'net_command', 'shell', 'win_shell', 'raw'] DEFAULT_BECOME_PASS = None DEFAULT_PASSWORD_CHARS = to_text(ascii_letters + digits + ".,:-_", errors='strict') # characters included in auto-generated passwords DEFAULT_SUDO_PASS = None diff --git a/lib/ansible/modules/network/basics/net_command.py b/lib/ansible/modules/network/basics/net_command.py new file mode 100644 index 0000000000..9c5707319c --- /dev/null +++ b/lib/ansible/modules/network/basics/net_command.py @@ -0,0 +1,131 @@ +#!/usr/bin/python +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# + +ANSIBLE_METADATA = { + 'status': ['preview'], + 'supported_by': 'core', + 'version': '1.0' +} + +DOCUMENTATION = """ +--- +module: net_command +version_added: "2.3" +author: "Peter Sprygada (@privateip)" +short_description: Executes a common on a remote network device +description: + - This module will take the command and execute it on the remote + device in a CLI shell. The command will outout will be returned + via the stdout return key. If an error is detected, the command + will return the error via the stderr key. +options: + free_form: + description: + - A free form command to run on the remote host. There is no + parameter actually named 'free_form'. See the examples . + required: true +notes: + - This module requires setting the Ansible connection type to network_cli + - This module will always set the changed return key to C(True) +""" + +EXAMPLES = """ +- name: execute show version + net_command: show version + +- name: run a series of commmands + net_command: "{{ item }}" + with_items: + - show interfaces + - show ip route + - show version +""" + +RETURN = """ +rc: + description: The command return code (0 means success) + returned: always + type: int + sample: 0 +stdout: + description: The command standard output + returned: always + type: string + sample: "Hostname: ios01\nFQDN: ios01.example.net" +stderr: + description: The command standard error + returned: always + type: string + sample: "shw hostname\r\n% Invalid input\r\nios01>" +stdout_lines: + description: The command standard output split in lines + returned: always + type: list + sample: ["Hostname: ios01", "FQDN: ios01.example.net"] +start: + description: The time the job started + returned: always + type: str + sample: "2016-11-16 10:38:15.126146" +end: + description: The time the job ended + returned: always + type: str + sample: "2016-11-16 10:38:25.595612" +delta: + description: The time elapsed to perform all operations + returned: always + type: str + sample: "0:00:10.469466" +""" +from ansible.module_utils.local import LocalAnsibleModule + +def main(): + """ main entry point for module execution + """ + argument_spec = dict( + _raw_params=dict() + ) + + module = LocalAnsibleModule(argument_spec=argument_spec, + supports_check_mode=False) + + if str(module.params['_raw_params']).strip() == '': + module.fail_json(rc=256, msg='no command given') + + result = {'changed': True} + + rc, out, err = module.exec_command(module.params['_raw_params']) + + try: + out = module.from_json(out) + except ValueError: + if out: + out = str(out).strip() + result['stdout_lines'] = out.split('\n') + + result.update({ + 'rc': rc, + 'stdout': out, + 'stderr': str(err).strip() + }) + + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/lib/ansible/parsing/mod_args.py b/lib/ansible/parsing/mod_args.py index fa4cec3d6c..079f2dbd58 100644 --- a/lib/ansible/parsing/mod_args.py +++ b/lib/ansible/parsing/mod_args.py @@ -31,6 +31,7 @@ from ansible.template import Templar RAW_PARAM_MODULES = ([ 'command', 'win_command', + 'net_command', 'shell', 'win_shell', 'script', @@ -164,7 +165,7 @@ class ModuleArgsParser: # only internal variables can start with an underscore, so # we don't allow users to set them directy in arguments - if args and action not in ('command', 'win_command', 'shell', 'win_shell', 'script', 'raw'): + if args and action not in ('command', 'net_command', 'win_command', 'shell', 'win_shell', 'script', 'raw'): for arg in args: arg = to_text(arg) if arg.startswith('_ansible_'): @@ -195,7 +196,7 @@ class ModuleArgsParser: args = thing elif isinstance(thing, string_types): # form is like: local_action: copy src=a dest=b ... pretty common - check_raw = action in ('command', 'win_command', 'shell', 'win_shell', 'script', 'raw') + check_raw = action in ('command', 'net_command', 'win_command', 'shell', 'win_shell', 'script', 'raw') args = parse_kv(thing, check_raw=check_raw) elif thing is None: # this can happen with modules which take no params, like ping: diff --git a/test/units/modules/network/__init__.py b/test/units/modules/network/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/units/modules/network/basics/__init__.py b/test/units/modules/network/basics/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/units/modules/network/basics/test_net_command.py b/test/units/modules/network/basics/test_net_command.py new file mode 100644 index 0000000000..8286480cb2 --- /dev/null +++ b/test/units/modules/network/basics/test_net_command.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python +# +# (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 . + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import os +import json + +from ansible.compat.tests import unittest +from ansible.compat.tests.mock import patch, MagicMock +from ansible.errors import AnsibleModuleExit +from ansible.modules.network.basics import net_command +from ansible.module_utils import basic +from ansible.module_utils.local import LocalAnsibleModule +from ansible.module_utils._text import to_bytes + + +def set_module_args(args): + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + +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: + pass + + fixture_data[path] = data + return data + + +class TestNetCommandModule(unittest.TestCase): + + def execute_module(self, command_response=None, failed=False, changed=True): + + if not command_response: + command_response = (256, '', 'no command response provided in test case') + + with patch.object(LocalAnsibleModule, 'exec_command') as mock_exec_command: + mock_exec_command.return_value = command_response + + with self.assertRaises(AnsibleModuleExit) as exc: + net_command.main() + + result = exc.exception.result + + if failed: + self.assertTrue(result.get('failed'), result) + else: + self.assertEqual(result.get('changed'), changed, result) + + return result + + def test_net_command_string(self): + """ + Test for all keys in the response + """ + set_module_args({'_raw_params': 'show version'}) + result = self.execute_module((0, 'ok', '')) + for key in ['rc', 'stdout', 'stderr', 'stdout_lines']: + self.assertIn(key, result) + + def test_net_command_json(self): + """ + The stdout_lines key should not be present when the return + string is a json data structure + """ + set_module_args({'_raw_params': 'show version'}) + result = self.execute_module((0, '{"key": "value"}', '')) + for key in ['rc', 'stdout', 'stderr']: + self.assertIn(key, result) + self.assertNotIn('stdout_lines', result) + + def test_net_command_missing_command(self): + """ + Test failure on missing command + """ + set_module_args({'_raw_params': ''}) + self.execute_module(failed=True) -- cgit v1.2.1