diff --git a/lib/ansible/modules/network/ios/ios_user.py b/lib/ansible/modules/network/ios/ios_user.py
new file mode 100644
index 00000000000..5c346082558
--- /dev/null
+++ b/lib/ansible/modules/network/ios/ios_user.py
@@ -0,0 +1,356 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# (c) 2017, Ansible by Red Hat, inc
+#
+# This file is part of Ansible by Red Hat
+#
+# 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 = {'metadata_version': '1.0',
+ 'status': ['preview'],
+ 'supported_by': 'core'}
+
+DOCUMENTATION = """
+---
+module: ios_user
+version_added: "2.4"
+author: "Trishna Guha (@trishnag)"
+short_description: Manage the collection of local users on Cisco IOS device
+description:
+ - This module provides declarative management of the local usernames
+ configured on network devices. It allows playbooks to manage
+ either individual usernames or the collection of usernames in the
+ current running config. It also supports purging usernames from the
+ configuration that are not explicitly defined.
+options:
+ users:
+ description:
+ - The set of username objects to be configured on the remote
+ Cisco IOS device. The list entries can either be the username
+ or a hash of username and properties. This argument is mutually
+ exclusive with the C(name) argument.
+ name:
+ description:
+ - The username to be configured on the Cisco IOS device.
+ This argument accepts a string value and is mutually exclusive
+ with the C(collection) argument.
+ Please note that this option is not same as C(provider username).
+ password:
+ description:
+ - The password to be configured on the Cisco IOS device. The
+ password needs to be provided in clear and it will be encrypted
+ on the device.
+ Please note that this option is not same as C(provider password).
+ update_password:
+ description:
+ - Since passwords are encrypted in the device running config, this
+ argument will instruct the module when to change the password. When
+ set to C(always), the password will always be updated in the device
+ and when set to C(on_create) the password will be updated only if
+ the username is created.
+ default: always
+ choices: ['on_create', 'always']
+ privilege:
+ description:
+ - The C(privilege) argument configures the privilege level of the
+ user when logged into the system. This argument accepts integer
+ values in the range of 1 to 15.
+ view:
+ description:
+ - Configures the view for the username in the
+ device running configuration. The argument accepts a string value
+ defining the view name. This argument does not check if the view
+ has been configured on the device.
+ nopassword:
+ description:
+ - Defines the username without assigning
+ a password. This will allow the user to login to the system
+ without being authenticated by a password.
+ type: bool
+ purge:
+ description:
+ - Instructs the module to consider the
+ resource definition absolute. It will remove any previously
+ configured usernames on the device with the exception of the
+ `admin` user (the current defined set of users).
+ type: bool
+ default: false
+ state:
+ description:
+ - Configures the state of the username definition
+ as it relates to the device operational configuration. When set
+ to I(present), the username(s) should be configured in the device active
+ configuration and when set to I(absent) the username(s) should not be
+ in the device active configuration
+ default: present
+ choices: ['present', 'absent']
+"""
+
+EXAMPLES = """
+- name: create a new user
+ ios_user:
+ name: ansible
+ nopassword: True
+ state: present
+- name: remove all users except admin
+ ios_user:
+ purge: yes
+- name: set multiple users to privilege level 15
+ ios_user:
+ users:
+ - name: netop
+ - name: netend
+ privilege: 15
+ state: present
+- name: set user view/role
+ ios_user:
+ name: netop
+ view: network-operator
+ state: present
+- name: Change Password for User netop
+ ios_user:
+ name: netop
+ password: "{{ new_password }}"
+ update_password: always
+ state: present
+"""
+
+RETURN = """
+commands:
+ description: The list of configuration mode commands to send to the device
+ returned: always
+ type: list
+ sample:
+ - username ansible secret password
+ - username admin secret admin
+"""
+
+import re
+
+from functools import partial
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils.ios import get_config, load_config
+from ansible.module_utils.six import iteritems
+from ansible.module_utils.ios import ios_argument_spec, check_args
+
+
+def validate_privilege(value, module):
+ if not 1 <= value <= 15:
+ module.fail_json(msg='privilege must be between 1 and 15, got %s' % value)
+
+
+def map_obj_to_commands(updates, module):
+ commands = list()
+ state = module.params['state']
+ update_password = module.params['update_password']
+
+ def needs_update(want, have, x):
+ return want.get(x) and (want.get(x) != have.get(x))
+
+ def add(command, want, x):
+ command.append('username %s %s' % (want['name'], x))
+
+ for update in updates:
+ want, have = update
+
+ if want['state'] == 'absent':
+ commands.append('no username %s' % want['name'])
+ continue
+
+ if needs_update(want, have, 'view'):
+ add(commands, want, 'view %s' % want['view'])
+
+ if needs_update(want, have, 'privilege'):
+ add(commands, want, 'privilege %s' % want['privilege'])
+
+ if needs_update(want, have, 'password'):
+ if update_password == 'always' or not have:
+ add(commands, want, 'secret %s' % want['password'])
+
+ if needs_update(want, have, 'nopassword'):
+ if want['nopassword']:
+ add(commands, want, 'nopassword')
+ else:
+ add(commands, want, 'no username %s nopassword' % want['name'])
+
+ return commands
+
+
+def parse_view(data):
+ match = re.search(r'view (\S+)', data, re.M)
+ if match:
+ return match.group(1)
+
+
+def parse_privilege(data):
+ match = re.search(r'privilege (\S+)', data, re.M)
+ if match:
+ return int(match.group(1))
+
+
+def map_config_to_obj(module):
+ data = get_config(module, flags=['| section username'])
+
+ match = re.findall(r'^username (\S+)', data, re.M)
+ if not match:
+ return list()
+
+ instances = list()
+
+ for user in set(match):
+ regex = r'username %s .+$' % user
+ cfg = re.findall(regex, data, re.M)
+ cfg = '\n'.join(cfg)
+ obj = {
+ 'name': user,
+ 'state': 'present',
+ 'nopassword': 'nopassword' in cfg,
+ 'password': None,
+ 'privilege': parse_privilege(cfg),
+ 'view': parse_view(cfg)
+ }
+ instances.append(obj)
+
+ return instances
+
+
+def get_param_value(key, item, module):
+ # if key doesn't exist in the item, get it from module.params
+ if not item.get(key):
+ value = module.params[key]
+
+ # if key does exist, do a type check on it to validate it
+ else:
+ value_type = module.argument_spec[key].get('type', 'str')
+ type_checker = module._CHECK_ARGUMENT_TYPES_DISPATCHER[value_type]
+ type_checker(item[key])
+ value = item[key]
+
+ # validate the param value (if validator func exists)
+ validator = globals().get('validate_%s' % key)
+ if all((value, validator)):
+ validator(value, module)
+
+ return value
+
+
+def map_params_to_obj(module):
+ users = module.params['users']
+ if not users:
+ if not module.params['name'] and module.params['purge']:
+ return list()
+ elif not module.params['name']:
+ module.fail_json(msg='username is required')
+ else:
+ collection = [{'name': module.params['name']}]
+ else:
+ collection = list()
+ for item in users:
+ if not isinstance(item, dict):
+ collection.append({'name': item})
+ elif 'name' not in item:
+ module.fail_json(msg='name is required')
+ else:
+ collection.append(item)
+
+ objects = list()
+
+ for item in collection:
+ get_value = partial(get_param_value, item=item, module=module)
+ item['password'] = get_value('password')
+ item['nopassword'] = get_value('nopassword')
+ item['privilege'] = get_value('privilege')
+ item['view'] = get_value('view')
+ item['state'] = get_value('state')
+ objects.append(item)
+
+ return objects
+
+
+def update_objects(want, have):
+ updates = list()
+ for entry in want:
+ item = next((i for i in have if i['name'] == entry['name']), None)
+ if all((item is None, entry['state'] == 'present')):
+ updates.append((entry, {}))
+ elif item:
+ for key, value in iteritems(entry):
+ if value and value != item[key]:
+ updates.append((entry, item))
+ return updates
+
+
+def main():
+ """ main entry point for module execution
+ """
+ argument_spec = dict(
+ users=dict(type='list', aliases=['collection']),
+ name=dict(),
+
+ password=dict(no_log=True),
+ nopassword=dict(type='bool'),
+ update_password=dict(default='always', choices=['on_create', 'always']),
+
+ privilege=dict(type='int'),
+ view=dict(aliases=['role']),
+
+ purge=dict(type='bool', default=False),
+ state=dict(default='present', choices=['present', 'absent'])
+ )
+
+ argument_spec.update(ios_argument_spec)
+ mutually_exclusive = [('name', 'users')]
+
+ module = AnsibleModule(argument_spec=argument_spec,
+ mutually_exclusive=mutually_exclusive,
+ supports_check_mode=True)
+
+ warnings = list()
+ check_args(module, warnings)
+
+ result = {'changed': False}
+ if warnings:
+ result['warnings'] = warnings
+
+ want = map_params_to_obj(module)
+ have = map_config_to_obj(module)
+
+ commands = map_obj_to_commands(update_objects(want, have), module)
+
+ if module.params['purge']:
+ want_users = [x['name'] for x in want]
+ have_users = [x['name'] for x in have]
+ for item in set(have_users).difference(want_users):
+ if item != 'admin':
+ commands.append('no username %s' % item)
+
+ result['commands'] = commands
+
+ # the ios cli prevents this by rule so capture it and display
+ # a nice failure message
+ if 'no username admin' in commands:
+ module.fail_json(msg='cannot delete the `admin` account')
+
+ if commands:
+ if not module.check_mode:
+ load_config(module, commands)
+ result['changed'] = True
+
+ module.exit_json(**result)
+
+if __name__ == '__main__':
+ main()
diff --git a/test/integration/ios.yaml b/test/integration/ios.yaml
index ea4d97e2ddc..d331922df18 100644
--- a/test/integration/ios.yaml
+++ b/test/integration/ios.yaml
@@ -14,3 +14,4 @@
- { role: ios_facts, when: "limit_to in ['*', 'ios_facts']" }
- { role: ios_template, when: "limit_to in ['*', 'ios_template']" }
- { role: ios_system, when: "limit_to in ['*', 'ios_system']" }
+ - { role: ios_user, when: "limit_to in ['*', 'ios_user']" }
diff --git a/test/integration/targets/ios_user/defaults/main.yaml b/test/integration/targets/ios_user/defaults/main.yaml
new file mode 100644
index 00000000000..5f709c5aac1
--- /dev/null
+++ b/test/integration/targets/ios_user/defaults/main.yaml
@@ -0,0 +1,2 @@
+---
+testcase: "*"
diff --git a/test/integration/targets/ios_user/meta/main.yaml b/test/integration/targets/ios_user/meta/main.yaml
new file mode 100644
index 00000000000..159cea8d383
--- /dev/null
+++ b/test/integration/targets/ios_user/meta/main.yaml
@@ -0,0 +1,2 @@
+dependencies:
+ - prepare_ios_tests
diff --git a/test/integration/targets/ios_user/tasks/cli.yaml b/test/integration/targets/ios_user/tasks/cli.yaml
new file mode 100644
index 00000000000..d675462dd02
--- /dev/null
+++ b/test/integration/targets/ios_user/tasks/cli.yaml
@@ -0,0 +1,15 @@
+---
+- name: collect all cli test cases
+ find:
+ paths: "{{ role_path }}/tests/cli"
+ patterns: "{{ testcase }}.yaml"
+ register: test_cases
+
+- name: set test_items
+ set_fact: test_items="{{ test_cases.files | map(attribute='path') | list }}"
+
+- name: run test case
+ include: "{{ test_case_to_run }}"
+ with_items: "{{ test_items }}"
+ loop_control:
+ loop_var: test_case_to_run
diff --git a/test/integration/targets/ios_user/tasks/main.yaml b/test/integration/targets/ios_user/tasks/main.yaml
new file mode 100644
index 00000000000..415c99d8b12
--- /dev/null
+++ b/test/integration/targets/ios_user/tasks/main.yaml
@@ -0,0 +1,2 @@
+---
+- { include: cli.yaml, tags: ['cli'] }
diff --git a/test/integration/targets/ios_user/tests/cli/basic.yaml b/test/integration/targets/ios_user/tests/cli/basic.yaml
new file mode 100644
index 00000000000..5d8c6314187
--- /dev/null
+++ b/test/integration/targets/ios_user/tests/cli/basic.yaml
@@ -0,0 +1,43 @@
+---
+- name: Create user
+ ios_user:
+ name: netend
+ privilege: 15
+ role: network-operator
+ state: present
+ authorize: yes
+ provider: "{{ cli }}"
+ register: result
+
+- assert:
+ that:
+ - 'result.changed == true'
+ - 'result.commands == ["username netend view network-operator", "username netend privilege 15"]'
+
+- name: Collection of users
+ ios_user:
+ users:
+ - name: test1
+ - name: test2
+ authorize: yes
+ state: present
+ view: network-admin
+ provider: "{{ cli }}"
+ register: result
+
+- assert:
+ that:
+ - 'result.changed == true'
+ - 'result.commands == ["username test1 view network-admin", "username test2 view network-admin"]'
+
+- name: tearDown
+ ios_user:
+ purge: yes
+ authorize: yes
+ provider: "{{ cli }}"
+ register: result
+
+- assert:
+ that:
+ - 'result.changed == true'
+ - 'result.commands == ["no username netend", "no username test1", "no username test2"]'
diff --git a/test/units/modules/network/ios/fixtures/ios_user_config.cfg b/test/units/modules/network/ios/fixtures/ios_user_config.cfg
new file mode 100644
index 00000000000..dd5b2095fa2
--- /dev/null
+++ b/test/units/modules/network/ios/fixtures/ios_user_config.cfg
@@ -0,0 +1,2 @@
+username admin view network-admin secret 5 $1$mdQIUxjg$3t3lzBpfKfITKvFm1uEIY.
+username ansible view network-admin secret 5 $1$3yWSXiIi$VdzV59ChiurrNdGxlDeAW/
diff --git a/test/units/modules/network/ios/test_ios_user.py b/test/units/modules/network/ios/test_ios_user.py
new file mode 100644
index 00000000000..bcc9fa6420b
--- /dev/null
+++ b/test/units/modules/network/ios/test_ios_user.py
@@ -0,0 +1,94 @@
+# (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 json
+
+from ansible.compat.tests.mock import patch
+from ansible.modules.network.ios import ios_user
+from .ios_module import TestIosModule, load_fixture, set_module_args
+
+
+class TestIosUserModule(TestIosModule):
+
+ module = ios_user
+
+ def setUp(self):
+ self.mock_get_config = patch('ansible.modules.network.ios.ios_user.get_config')
+ self.get_config = self.mock_get_config.start()
+
+ self.mock_load_config = patch('ansible.modules.network.ios.ios_user.load_config')
+ self.load_config = self.mock_load_config.start()
+
+ def tearDown(self):
+ self.mock_get_config.stop()
+ self.mock_load_config.stop()
+
+ def load_fixtures(self, commands=None, transport='cli'):
+ self.get_config.return_value = load_fixture('ios_user_config.cfg')
+ self.load_config.return_value = dict(diff=None, session='session')
+
+ def test_ios_user_create(self):
+ set_module_args(dict(name='test', nopassword=True))
+ result = self.execute_module(changed=True)
+ self.assertEqual(result['commands'], ['username test nopassword'])
+
+ def test_ios_user_delete(self):
+ set_module_args(dict(name='ansible', state='absent'))
+ result = self.execute_module(changed=True)
+ self.assertEqual(result['commands'], ['no username ansible'])
+
+ def test_ios_user_password(self):
+ set_module_args(dict(name='ansible', password='test'))
+ result = self.execute_module(changed=True)
+ self.assertEqual(result['commands'], ['username ansible secret test'])
+
+ def test_ios_user_privilege(self):
+ set_module_args(dict(name='ansible', privilege=15))
+ result = self.execute_module(changed=True)
+ self.assertEqual(result['commands'], ['username ansible privilege 15'])
+
+ def test_ios_user_privilege_invalid(self):
+ set_module_args(dict(name='ansible', privilege=25))
+ self.execute_module(failed=True)
+
+ def test_ios_user_purge(self):
+ set_module_args(dict(purge=True))
+ result = self.execute_module(changed=True)
+ self.assertEqual(result['commands'], ['no username ansible'])
+
+ def test_ios_user_view(self):
+ set_module_args(dict(name='ansible', view='test'))
+ result = self.execute_module(changed=True)
+ self.assertEqual(result['commands'], ['username ansible view test'])
+
+ def test_ios_user_update_password_changed(self):
+ set_module_args(dict(name='test', password='test', update_password='on_create'))
+ result = self.execute_module(changed=True)
+ self.assertEqual(result['commands'], ['username test secret test'])
+
+ def test_ios_user_update_password_on_create_ok(self):
+ set_module_args(dict(name='ansible', password='test', update_password='on_create'))
+ self.execute_module()
+
+ def test_ios_user_update_password_always(self):
+ set_module_args(dict(name='ansible', password='test', update_password='always'))
+ result = self.execute_module(changed=True)
+ self.assertEqual(result['commands'], ['username ansible secret test'])