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)