diff --git a/lib/ansible/module_utils/network/cnos/cnos.py b/lib/ansible/module_utils/network/cnos/cnos.py
index d8be39df29..e986cdf7be 100644
--- a/lib/ansible/module_utils/network/cnos/cnos.py
+++ b/lib/ansible/module_utils/network/cnos/cnos.py
@@ -50,6 +50,7 @@ from ansible.module_utils.connection import ConnectionError
_DEVICE_CONFIGS = {}
_CONNECTION = None
+_VALID_USER_ROLES = ['network-admin', 'network-operator']
cnos_provider_spec = {
'host': dict(),
@@ -88,6 +89,10 @@ def check_args(module, warnings):
pass
+def get_user_roles():
+ return _VALID_USER_ROLES
+
+
def get_connection(module):
global _CONNECTION
if _CONNECTION:
diff --git a/lib/ansible/modules/network/cnos/cnos_logging.py b/lib/ansible/modules/network/cnos/cnos_logging.py
index dceb746595..1bdfd50cec 100644
--- a/lib/ansible/modules/network/cnos/cnos_logging.py
+++ b/lib/ansible/modules/network/cnos/cnos_logging.py
@@ -289,7 +289,7 @@ def map_config_to_obj(module):
obj.append({'dest': logs[1], 'level': logs[2]})
elif logs[1] == 'logfile':
level = '5'
- if logs[3] is not None:
+ if index > 3 and logs[3].isdigit():
level = logs[3]
size = '10485760'
if len(logs) > 4:
@@ -299,7 +299,7 @@ def map_config_to_obj(module):
level = '5'
facility = None
- if logs[3].isdigit():
+ if index > 3 and logs[3].isdigit():
level = logs[3]
if index > 3 and logs[3] == 'facility':
facility = logs[4]
diff --git a/lib/ansible/modules/network/cnos/cnos_user.py b/lib/ansible/modules/network/cnos/cnos_user.py
new file mode 100644
index 0000000000..087f6714fc
--- /dev/null
+++ b/lib/ansible/modules/network/cnos/cnos_user.py
@@ -0,0 +1,391 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+#
+# Copyright (C) 2019 Lenovo.
+# (c) 2017, Ansible by 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 .
+#
+# Module to work on management of local users on Lenovo CNOS Switches
+# Lenovo Networking
+#
+ANSIBLE_METADATA = {'metadata_version': '1.1',
+ 'status': ['preview'],
+ 'supported_by': 'community'}
+
+DOCUMENTATION = """
+---
+module: cnos_user
+version_added: "2.8"
+author: "Anil Kumar Muraleedharan (@amuraleedhar)"
+short_description: Manage the collection of local users on Lenovo CNOS devices
+description:
+ - This module provides declarative management of the local usernames
+ configured on Lenovo CNOS 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:
+ aggregate:
+ description:
+ - The set of username objects to be configured on the remote
+ Lenovo CNOS 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.
+ aliases: ['users', 'collection']
+ name:
+ description:
+ - The username to be configured on the remote Lenovo CNOS
+ device. This argument accepts a string value and is mutually
+ exclusive with the C(aggregate) argument.
+ configured_password:
+ description:
+ - The password to be configured on the network device. The
+ password needs to be provided in cleartext 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']
+ role:
+ description:
+ - The C(role) argument configures the role for the username in the
+ device running configuration. The argument accepts a string value
+ defining the role name. This argument does not check if the role
+ has been configured on the device.
+ aliases: ['roles']
+ sshkey:
+ description:
+ - The C(sshkey) argument defines the SSH public key to configure
+ for the username. This argument accepts a valid SSH key value.
+ purge:
+ description:
+ - The C(purge) argument 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 which cannot be deleted per cnos constraints.
+ type: bool
+ default: 'no'
+ state:
+ description:
+ - The C(state) argument 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
+ cnos_user:
+ name: ansible
+ sshkey: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
+ state: present
+
+- name: remove all users except admin
+ cnos_user:
+ purge: yes
+
+- name: set multiple users role
+ aggregate:
+ - name: netop
+ - name: netend
+ role: network-operator
+ state: present
+"""
+
+RETURN = """
+commands:
+ description: The list of configuration mode commands to send to the device
+ returned: always
+ type: list
+ sample:
+ - name ansible
+ - name ansible password password
+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"
+"""
+import re
+
+from copy import deepcopy
+from functools import partial
+
+from ansible.module_utils.network.cnos.cnos import run_commands, load_config
+from ansible.module_utils.network.cnos.cnos import get_config
+from ansible.module_utils.network.cnos.cnos import cnos_argument_spec
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils.six import string_types, iteritems
+from ansible.module_utils.network.common.utils import to_list
+from ansible.module_utils.network.common.utils import remove_default_spec
+from ansible.module_utils.network.cnos.cnos import get_user_roles
+
+
+def validate_roles(value, module):
+ for item in value:
+ if item not in get_user_roles():
+ module.fail_json(msg='invalid role specified')
+
+
+def map_obj_to_commands(updates, module):
+ commands = list()
+ state = module.params['state']
+ update_password = module.params['update_password']
+
+ for update in updates:
+ want, have = update
+
+ def needs_update(x):
+ return want.get(x) and (want.get(x) != have.get(x))
+
+ def add(x):
+ return commands.append('username %s %s' % (want['name'], x))
+
+ def remove(x):
+ return commands.append('no username %s %s' % (want['name'], x))
+
+ if want['state'] == 'absent':
+ commands.append('no username %s' % want['name'])
+ continue
+
+ if want['state'] == 'present' and not have:
+ commands.append('username %s' % want['name'])
+
+ if needs_update('configured_password'):
+ if update_password == 'always' or not have:
+ add('password %s' % want['configured_password'])
+
+ if needs_update('sshkey'):
+ add('sshkey %s' % want['sshkey'])
+
+ if want['roles']:
+ if have:
+ for item in set(have['roles']).difference(want['roles']):
+ remove('role %s' % item)
+
+ for item in set(want['roles']).difference(have['roles']):
+ add('role %s' % item)
+ else:
+ for item in want['roles']:
+ add('role %s' % item)
+
+ return commands
+
+
+def parse_password(data):
+ if 'no password set' in data:
+ return None
+ return ''
+
+
+def parse_roles(data):
+ roles = list()
+ if 'role:' in data:
+ items = data.split()
+ my_item = items[items.index('role:') + 1]
+ roles.append(my_item)
+ return roles
+
+
+def parse_username(data):
+ name = data.split(' ', 1)[0]
+ username = name[1:]
+ return username
+
+
+def parse_sshkey(data):
+ key = None
+ if 'sskkey:' in data:
+ items = data.split()
+ key = items[items.index('sshkey:') + 1]
+ return key
+
+
+def map_config_to_obj(module):
+ out = run_commands(module, ['show user-account'])
+ data = out[0]
+ objects = list()
+ datum = data.split('User')
+
+ for item in datum:
+ objects.append({
+ 'name': parse_username(item),
+ 'configured_password': parse_password(item),
+ 'sshkey': parse_sshkey(item),
+ 'roles': parse_roles(item),
+ 'state': 'present'
+ })
+ return objects
+
+
+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]
+
+ return value
+
+
+def map_params_to_obj(module):
+ aggregate = module.params['aggregate']
+ if not aggregate:
+ 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 aggregate:
+ 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.update({
+ 'configured_password': get_value('configured_password'),
+ 'sshkey': get_value('sshkey'),
+ 'roles': get_value('roles'),
+ 'state': get_value('state')
+ })
+
+ for key, value in iteritems(item):
+ if value:
+ # validate the param value (if validator func exists)
+ validator = globals().get('validate_%s' % key)
+ if all((value, validator)):
+ validator(value, module)
+
+ 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
+ """
+ element_spec = dict(
+ name=dict(),
+ configured_password=dict(no_log=True),
+ update_password=dict(default='always', choices=['on_create', 'always']),
+ roles=dict(type='list', aliases=['role']),
+ sshkey=dict(),
+ state=dict(default='present', choices=['present', 'absent'])
+ )
+
+ aggregate_spec = deepcopy(element_spec)
+
+ # remove default in aggregate spec, to handle common arguments
+ remove_default_spec(aggregate_spec)
+
+ argument_spec = dict(
+ aggregate=dict(type='list', elements='dict',
+ options=aggregate_spec, aliases=['collection', 'users']),
+ purge=dict(type='bool', default=False)
+ )
+
+ argument_spec.update(element_spec)
+
+ mutually_exclusive = [('name', 'aggregate')]
+
+ module = AnsibleModule(argument_spec=argument_spec,
+ mutually_exclusive=mutually_exclusive,
+ supports_check_mode=True)
+
+ warnings = list()
+
+ result = {'changed': False}
+ 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':
+ if not item.strip():
+ continue
+ item = item.replace("\\", "\\\\")
+ commands.append('no username %s' % item)
+
+ result['commands'] = commands
+
+ # the cnos cli prevents this by rule but still
+ 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/targets/cnos_user/aliases b/test/integration/targets/cnos_user/aliases
new file mode 100644
index 0000000000..cdb5033353
--- /dev/null
+++ b/test/integration/targets/cnos_user/aliases
@@ -0,0 +1,2 @@
+# No Lenovo Switch simulator yet, so not enabled
+unsupported
\ No newline at end of file
diff --git a/test/integration/targets/cnos_user/cnos_user_sample_hosts b/test/integration/targets/cnos_user/cnos_user_sample_hosts
new file mode 100644
index 0000000000..7274107e49
--- /dev/null
+++ b/test/integration/targets/cnos_user/cnos_user_sample_hosts
@@ -0,0 +1,14 @@
+# You have to paste this dummy information in /etc/ansible/hosts
+# Notes:
+# - Comments begin with the '#' character
+# - Blank lines are ignored
+# - Groups of hosts are delimited by [header] elements
+# - You can enter hostnames or ip Addresses
+# - A hostname/ip can be a member of multiple groups
+#
+# In the /etc/ansible/hosts file u have to enter [cnos_user_sample] tag
+# Following you should specify IP Addresses details
+# Please change and with appropriate value for your switch.
+
+[cnos_sample_sample]
+10.241.107.39 ansible_network_os=cnos ansible_ssh_user=admin ansible_ssh_pass=admin
diff --git a/test/integration/targets/cnos_user/defaults/main.yaml b/test/integration/targets/cnos_user/defaults/main.yaml
new file mode 100644
index 0000000000..5f709c5aac
--- /dev/null
+++ b/test/integration/targets/cnos_user/defaults/main.yaml
@@ -0,0 +1,2 @@
+---
+testcase: "*"
diff --git a/test/integration/targets/cnos_user/tasks/cli.yaml b/test/integration/targets/cnos_user/tasks/cli.yaml
new file mode 100644
index 0000000000..9b62eaba65
--- /dev/null
+++ b/test/integration/targets/cnos_user/tasks/cli.yaml
@@ -0,0 +1,27 @@
+---
+- name: collect common test cases
+ find:
+ paths: "{{ role_path }}/tests/common"
+ patterns: "{{ testcase }}.yaml"
+ connection: local
+ register: test_cases
+
+- name: collect cli test cases
+ find:
+ paths: "{{ role_path }}/tests/cli"
+ patterns: "{{ testcase }}.yaml"
+ connection: local
+ register: cli_cases
+
+- set_fact:
+ test_cases:
+ files: "{{ test_cases.files }} + {{ cli_cases.files }}"
+
+- name: set test_items
+ set_fact: test_items="{{ test_cases.files | map(attribute='path') | list }}"
+
+- name: run test cases (connection=network_cli)
+ include: "{{ test_case_to_run }} ansible_connection=network_cli connection={{ cli }}"
+ with_items: "{{ test_items }}"
+ loop_control:
+ loop_var: test_case_to_run
diff --git a/test/integration/targets/cnos_user/tasks/main.yaml b/test/integration/targets/cnos_user/tasks/main.yaml
new file mode 100644
index 0000000000..415c99d8b1
--- /dev/null
+++ b/test/integration/targets/cnos_user/tasks/main.yaml
@@ -0,0 +1,2 @@
+---
+- { include: cli.yaml, tags: ['cli'] }
diff --git a/test/integration/targets/cnos_user/tests/common/basic.yaml b/test/integration/targets/cnos_user/tests/common/basic.yaml
new file mode 100644
index 0000000000..601e888141
--- /dev/null
+++ b/test/integration/targets/cnos_user/tests/common/basic.yaml
@@ -0,0 +1,58 @@
+---
+- debug: msg="START connection={{ ansible_connection }} cnos_user basic test"
+
+- name: Remove old entries of user
+ cnos_user:
+ aggregate:
+ - { name: ansibletest1 }
+ - { name: ansibletest2 }
+ - { name: ansibletest3 }
+# provider: "{{ connection }}"
+ configured_password: admin
+ state: absent
+
+# Start tests
+- name: Create user
+ cnos_user:
+ name: ansibletest1
+ roles: network-operator
+# provider: "{{ connection }}"
+ state: present
+ register: result
+
+- assert:
+ that:
+ - 'result.changed == true'
+ - '"username" in result.commands[0]'
+ - '"role network-operator" in result.commands[1]'
+
+- name: Collection of users
+ cnos_user:
+ aggregate:
+ - { name: ansibletest2 }
+ - { name: ansibletest3 }
+# provider: "{{ connection }}"
+ state: present
+ roles: network-admin
+ register: result
+
+- assert:
+ that:
+ - 'result.changed == true'
+
+- name: tearDown
+ cnos_user:
+ aggregate:
+ - { name: ansibletest1 }
+ - { name: ansibletest2 }
+ - { name: ansibletest3 }
+# provider: "{{ connection }}"
+ state: absent
+ register: result
+
+- assert:
+ that:
+ - 'result.changed == true'
+ - '"no username" in result.commands[0]'
+
+- debug: msg="END connection={{ ansible_connection }} cnos_user basic test"
diff --git a/test/integration/targets/cnos_user/tests/common/sanity.yaml b/test/integration/targets/cnos_user/tests/common/sanity.yaml
new file mode 100644
index 0000000000..7e86e4faa7
--- /dev/null
+++ b/test/integration/targets/cnos_user/tests/common/sanity.yaml
@@ -0,0 +1,84 @@
+---
+- debug: msg="START connection={{ ansible_connection }} cnos_user parameter test"
+
+
+- block:
+ - name: Create user
+ cnos_user: &configure
+ name: netend
+ configured_password: Hello!234
+ update_password: on_create
+ roles: network-operator
+ state: present
+ register: result
+
+ - assert: &true
+ that:
+ - 'result.changed == true'
+
+ - block:
+ - name: conf idempotency
+ cnos_user: *configure
+ register: result
+
+ - assert: &false
+ that:
+ - 'result.changed == false'
+
+ - name: Remove user
+ cnos_user: &remove
+ name: netend
+ state: absent
+ register: result
+
+ - assert: *true
+
+ - name: remove idempotency
+ cnos_user: *remove
+ register: result
+
+ - assert: *false
+
+ - name: Collection of users
+ cnos_user: &coll
+ users:
+ - name: test1
+ - name: test2
+ configured_password: Hello!234
+ update_password: on_create
+ state: present
+ roles:
+ - network-admin
+ - network-operator
+ register: result
+
+ - assert: *true
+
+ - block:
+ - name: users idempotency
+ cnos_user: *coll
+ register: result
+
+ - assert: *true
+
+ - name: tearDown
+ cnos_user: &tear
+ name: ansible
+ purge: yes
+ register: result
+
+ - assert: *true
+
+ - name: teardown idempotency
+ cnos_user: *tear
+ register: result
+
+ - assert: *false
+
+ always:
+ - name: tearDown
+ cnos_user: *tear
+ register: result
+ ignore_errors: yes
+
+- debug: msg="END connection={{ ansible_connection }} cnos_user parameter test"
diff --git a/test/units/modules/network/cnos/fixtures/cnos_user_config.cfg b/test/units/modules/network/cnos/fixtures/cnos_user_config.cfg
new file mode 100644
index 0000000000..5a39ba3d8e
--- /dev/null
+++ b/test/units/modules/network/cnos/fixtures/cnos_user_config.cfg
@@ -0,0 +1,8 @@
+User:admin
+ role: network-admin
+
+User:ansible
+ role: network-operator
+no password set. Local login not allowed
+this user is created by remote authentication
+Remote login through RADIUS/TACACS+ is possible
diff --git a/test/units/modules/network/cnos/test_cnos_user.py b/test/units/modules/network/cnos/test_cnos_user.py
new file mode 100644
index 0000000000..35ca012013
--- /dev/null
+++ b/test/units/modules/network/cnos/test_cnos_user.py
@@ -0,0 +1,89 @@
+# 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
+
+from units.compat.mock import patch
+from ansible.modules.network.cnos import cnos_user
+from units.modules.utils import set_module_args
+from .cnos_module import TestCnosModule, load_fixture
+
+
+class TestCnosUserModule(TestCnosModule):
+
+ module = cnos_user
+
+ def setUp(self):
+ super(TestCnosUserModule, self).setUp()
+ self.mock_get_config = patch('ansible.modules.network.cnos.cnos_user.get_config')
+ self.get_config = self.mock_get_config.start()
+ self.mock_load_config = patch('ansible.modules.network.cnos.cnos_user.load_config')
+ self.load_config = self.mock_load_config.start()
+ self.mock_run_commands = patch('ansible.modules.network.cnos.cnos_user.run_commands')
+ self.run_commands = self.mock_run_commands.start()
+
+ def tearDown(self):
+ super(TestCnosUserModule, self).tearDown()
+
+ self.mock_get_config.stop()
+ self.mock_load_config.stop()
+ self.mock_run_commands.stop()
+
+ def load_fixtures(self, commands=None, transport='cli'):
+ self.get_config.return_value = load_fixture('cnos_user_config.cfg')
+ self.load_config.return_value = dict(diff=None, session='session')
+ self.run_commands.return_value = [load_fixture('cnos_user_config.cfg')]
+
+ def test_cnos_user_create(self):
+ set_module_args(dict(name='test', configured_password='Anil'))
+ commands = ['username test', 'username test password Anil']
+ self.execute_module(changed=True, commands=commands)
+
+ def test_cnos_user_delete(self):
+ set_module_args(dict(name='ansible', state='absent'))
+ commands = []
+ self.execute_module(changed=False, commands=commands)
+
+ def test_cnos_user_password(self):
+ set_module_args(dict(name='ansible', configured_password='test'))
+ commands = ['username ansible', 'username ansible password test']
+ self.execute_module(changed=True, commands=commands)
+
+ def test_cnos_user_purge(self):
+ set_module_args(dict(purge=True))
+ commands = ['no username admin\n', 'no username ansible\n']
+ self.execute_module(changed=True, commands=commands)
+
+ def test_cnos_user_role(self):
+ set_module_args(dict(name='ansible', role='network-admin', configured_password='test'))
+ result = self.execute_module(changed=True)
+ self.assertIn('username ansible role network-admin', result['commands'])
+
+ def test_cnos_user_sshkey(self):
+ set_module_args(dict(name='ansible', sshkey='test'))
+ commands = ['username ansible', 'username ansible sshkey test']
+ self.execute_module(changed=True, commands=commands)
+
+ def test_cnos_user_update_password_changed(self):
+ set_module_args(dict(name='test', configured_password='test', update_password='on_create'))
+ commands = ['username test', 'username test password test']
+ self.execute_module(changed=True, commands=commands)
+
+ def test_cnos_user_update_password_always(self):
+ set_module_args(dict(name='ansible', configured_password='test', update_password='always'))
+ commands = ['username ansible', 'username ansible password test']
+ self.execute_module(changed=True, commands=commands)