diff --git a/lib/ansible/module_utils/network/cnos/cnos.py b/lib/ansible/module_utils/network/cnos/cnos.py index d8be39df29f..e986cdf7be3 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 dceb7465958..1bdfd50cec5 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 00000000000..087f6714fc8 --- /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 00000000000..cdb50333531 --- /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 00000000000..7274107e49e --- /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 00000000000..5f709c5aac1 --- /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 00000000000..9b62eaba65e --- /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 00000000000..415c99d8b12 --- /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 00000000000..601e888141c --- /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 00000000000..7e86e4faa77 --- /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 00000000000..5a39ba3d8e0 --- /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 00000000000..35ca0120138 --- /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)