From baa3dafff900f6931deffdb954704062eb135423 Mon Sep 17 00:00:00 2001 From: ambroise Date: Sat, 12 Aug 2023 00:09:53 +0200 Subject: [PATCH] os_user_global - Replace old module ios_user Replace the module ios_user with a more standard file coverage. And also take correctly some parts of the commands (by example, the current module doesn't take correctly the `privilege`part of the username command --- .gitignore | 1 + README.md | 1 + changelogs/fragments/ios_user_global.yml | 6 + docs/cisco.ios.ios_facts_module.rst | 2 +- meta/runtime.yml | 6 + plugins/action/user_global.py | 1 + .../ios/argspec/user_global/__init__.py | 0 .../ios/argspec/user_global/user_global.py | 104 ++++ .../ios/config/user_global/__init__.py | 0 .../ios/config/user_global/user_global.py | 120 +++++ .../module_utils/network/ios/facts/facts.py | 4 + .../network/ios/facts/user_global/__init__.py | 0 .../ios/facts/user_global/user_global.py | 84 ++++ .../network/ios/rm_templates/user_global.py | 105 ++++ plugins/modules/ios_facts.py | 2 +- plugins/modules/ios_user_global.py | 476 ++++++++++++++++++ .../ios/fixtures/ios_user_global_config.cfg | 1 + .../network/ios/test_ios_user_global.py | 330 ++++++++++++ 18 files changed, 1241 insertions(+), 2 deletions(-) create mode 100644 changelogs/fragments/ios_user_global.yml create mode 120000 plugins/action/user_global.py create mode 100644 plugins/module_utils/network/ios/argspec/user_global/__init__.py create mode 100644 plugins/module_utils/network/ios/argspec/user_global/user_global.py create mode 100644 plugins/module_utils/network/ios/config/user_global/__init__.py create mode 100644 plugins/module_utils/network/ios/config/user_global/user_global.py create mode 100644 plugins/module_utils/network/ios/facts/user_global/__init__.py create mode 100644 plugins/module_utils/network/ios/facts/user_global/user_global.py create mode 100644 plugins/module_utils/network/ios/rm_templates/user_global.py create mode 100644 plugins/modules/ios_user_global.py create mode 100644 tests/unit/modules/network/ios/fixtures/ios_user_global_config.cfg create mode 100644 tests/unit/modules/network/ios/test_ios_user_global.py diff --git a/.gitignore b/.gitignore index 1f9858c458..cf536f6174 100644 --- a/.gitignore +++ b/.gitignore @@ -114,5 +114,6 @@ venv.bak/ *.code-workspace .vscode/ .DS_Store +*.swp changelogs/.plugin-cache.yaml diff --git a/README.md b/README.md index 18c333bd91..b0207c9e46 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Name | Description [cisco.ios.ios_static_routes](https://github.com/ansible-collections/cisco.ios/blob/main/docs/cisco.ios.ios_static_routes_module.rst)|Resource module to configure static routes. [cisco.ios.ios_system](https://github.com/ansible-collections/cisco.ios/blob/main/docs/cisco.ios.ios_system_module.rst)|Module to manage the system attributes. [cisco.ios.ios_user](https://github.com/ansible-collections/cisco.ios/blob/main/docs/cisco.ios.ios_user_module.rst)|Module to manage the aggregates of local users. +[cisco.ios.ios_user_global](https://github.com/ansible-collections/cisco.ios/blob/main/docs/cisco.ios.ios_user_global_module.rst)|Resource module to configure user and enable [cisco.ios.ios_vlans](https://github.com/ansible-collections/cisco.ios/blob/main/docs/cisco.ios.ios_vlans_module.rst)|Resource module to configure VLANs. [cisco.ios.ios_vrf](https://github.com/ansible-collections/cisco.ios/blob/main/docs/cisco.ios.ios_vrf_module.rst)|Module to configure VRF definitions. diff --git a/changelogs/fragments/ios_user_global.yml b/changelogs/fragments/ios_user_global.yml new file mode 100644 index 0000000000..38fa23570e --- /dev/null +++ b/changelogs/fragments/ios_user_global.yml @@ -0,0 +1,6 @@ +--- +minor_changes: + - ios_user_global - Put a module to replace the module ios_user with a files covered by standards (construct with `resource_module_models`) + +bugfixes: + - gitignore - Add the line to not commit temporary files from VIM (`*.swp`) diff --git a/docs/cisco.ios.ios_facts_module.rst b/docs/cisco.ios.ios_facts_module.rst index 39b210f19d..debb908930 100644 --- a/docs/cisco.ios.ios_facts_module.rst +++ b/docs/cisco.ios.ios_facts_module.rst @@ -65,7 +65,7 @@ Parameters -
When supplied, this argument will restrict the facts collected to a given subset. Possible values for this argument include all and the resources like interfaces, vlans etc. Can specify a list of values to include a larger subset. Values can also be used with an initial ! to specify that a specific subset should not be collected. Valid subsets are 'bgp_global', 'l3_interfaces', 'lag_interfaces', 'ntp_global', 'acls', 'hostname', 'interfaces', 'lldp_interfaces', 'logging_global', 'ospf_interfaces', 'ospfv2', 'prefix_lists', 'static_routes', 'acl_interfaces', 'all', 'bgp_address_family', 'l2_interfaces', 'lacp', 'lacp_interfaces', 'lldp_global', 'ospfv3', 'snmp_server', 'vlans', 'service'.
+
When supplied, this argument will restrict the facts collected to a given subset. Possible values for this argument include all and the resources like interfaces, vlans etc. Can specify a list of values to include a larger subset. Values can also be used with an initial ! to specify that a specific subset should not be collected. Valid subsets are 'bgp_global', 'l3_interfaces', 'lag_interfaces', 'ntp_global', 'acls', 'hostname', 'interfaces', 'lldp_interfaces', 'logging_global', 'ospf_interfaces', 'ospfv2', 'prefix_lists', 'static_routes', 'acl_interfaces', 'all', 'bgp_address_family', 'l2_interfaces', 'lacp', 'lacp_interfaces', 'lldp_global', 'ospfv3', 'snmp_server', 'vlans', 'service', 'user_global'.
diff --git a/meta/runtime.yml b/meta/runtime.yml index 55584b22f6..d429fbdb3e 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -63,6 +63,8 @@ plugin_routing: redirect: cisco.ios.ios ios_system: redirect: cisco.ios.ios + ios_user_global: + redirect: cisco.ios.ios l2_interfaces: redirect: cisco.ios.ios l3_interfaces: @@ -107,6 +109,8 @@ plugin_routing: redirect: cisco.ios.ios user: redirect: cisco.ios.ios + user_global: + redirect: cisco.ios.ios vlans: redirect: cisco.ios.ios vrf: @@ -202,6 +206,8 @@ plugin_routing: redirect: cisco.ios.ios_system user: redirect: cisco.ios.ios_user + user_global: + redirect: cisco.ios.ios_user_global vlans: redirect: cisco.ios.ios_vlans vrf: diff --git a/plugins/action/user_global.py b/plugins/action/user_global.py new file mode 120000 index 0000000000..7747aa9dd1 --- /dev/null +++ b/plugins/action/user_global.py @@ -0,0 +1 @@ +ios.py \ No newline at end of file diff --git a/plugins/module_utils/network/ios/argspec/user_global/__init__.py b/plugins/module_utils/network/ios/argspec/user_global/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/module_utils/network/ios/argspec/user_global/user_global.py b/plugins/module_utils/network/ios/argspec/user_global/user_global.py new file mode 100644 index 0000000000..adfa3f3fc7 --- /dev/null +++ b/plugins/module_utils/network/ios/argspec/user_global/user_global.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Copyright 2023 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +############################################# +# WARNING # +############################################# +# +# This file is auto generated by the +# ansible.content_builder. +# +# Manually editing this file is not advised. +# +# To update the argspec make the desired changes +# in the documentation in the module file and re-run +# ansible.content_builder commenting out +# the path to external 'docstring' in build.yaml. +# +############################################## + +""" +The arg spec for the ios_user_global module +""" + + +class User_globalArgs(object): # pylint: disable=R0903 + """The arg spec for the ios_user_global module""" + + argument_spec = { + "config": { + "type": "dict", + "options": { + "enable": { + "elements": "dict", + "options": { + "password": { + "type": "dict", + "options": { + "type": { + "choices": ["password", "secret"], + "default": "secret", + "type": "str", + }, + "hash": { + "choices": [0, 5, 6, 7, 8, 9], + "default": 0, + "type": "int", + }, + "value": {"type": "str", "required": True}, + }, + "no_log": True, + }, + "level": {"type": "int"}, + }, + "type": "list", + }, + "users": { + "elements": "dict", + "options": { + "name": {"type": "str", "required": True}, + "description": {"type": "str"}, + "password": { + "type": "dict", + "options": { + "type": { + "choices": ["password", "secret"], + "default": "secret", + "type": "str", + }, + "hash": { + "choices": [0, 5, 6, 7, 8, 9], + "default": 0, + "type": "int", + }, + "value": {"type": "str", "required": True}, + }, + "no_log": True, + }, + "privilege": {"type": "int"}, + }, + "type": "list", + }, + }, + }, + "running_config": {"type": "str"}, + "state": { + "choices": [ + "merged", + "overridden", + "deleted", + "rendered", + "gathered", + "parsed", + ], + "default": "merged", + "type": "str", + }, + } # pylint: disable=C0301 diff --git a/plugins/module_utils/network/ios/config/user_global/__init__.py b/plugins/module_utils/network/ios/config/user_global/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/module_utils/network/ios/config/user_global/user_global.py b/plugins/module_utils/network/ios/config/user_global/user_global.py new file mode 100644 index 0000000000..ee4a03db71 --- /dev/null +++ b/plugins/module_utils/network/ios/config/user_global/user_global.py @@ -0,0 +1,120 @@ +# +# -*- coding: utf-8 -*- +# Copyright 2023 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +""" +The ios_user_global config file. +It is in this file where the current configuration (as dict) +is compared to the provided configuration (as dict) and the command set +necessary to bring the current configuration to its desired end-state is +created. +""" + +from copy import deepcopy + +from ansible.module_utils.six import iteritems +from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.rm_base.resource_module import ( + ResourceModule, +) +from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.utils import ( + dict_merge, +) + +from ansible_collections.cisco.ios.plugins.module_utils.network.ios.facts.facts import Facts +from ansible_collections.cisco.ios.plugins.module_utils.network.ios.rm_templates.user_global import ( + User_globalTemplate, +) + + +class User_global(ResourceModule): + """ + The ios_user_global config class + """ + + def __init__(self, module): + super(User_global, self).__init__( + empty_fact_val={}, + facts_module=Facts(module), + module=module, + resource="user_global", + tmplt=User_globalTemplate(), + ) + self.parsers = [] + self.list_parsers = [ + "enable", + "users", + ] + + def execute_module(self): + """Execute the module + + :rtype: A dictionary + :returns: The result from module execution + """ + if self.state not in ["parsed", "gathered"]: + self.generate_commands() + self.run_commands() + return self.result + + def generate_commands(self): + """Generate configuration commands to send based on + want, have and desired state. + """ + wantd = self._users_list_to_dict(self.want) + haved = self._users_list_to_dict(self.have) + + # if state is merged, merge want onto have and then compare + if self.state == "merged": + wantd = dict_merge(haved, wantd) + + # if state is deleted, empty out wantd and set haved to wantd + if self.state == "deleted": + wantd = {} + + self._compare(want=wantd, have=haved) + + def _compare(self, want, have): + """Leverages the base class `compare()` method and + populates the list of commands to be run by comparing + the `want` and `have` data with the `parsers` defined + for the User_global network resource. + """ + self.compare(parsers=self.parsers, want=want, have=have) + self._compare_lists_attrs(want, have) + + def _compare_lists_attrs(self, want, have): + """Compare list of dict""" + for _parser in self.list_parsers: + i_want = want.get(_parser, {}) + i_have = have.get(_parser, {}) + for key, wanting in iteritems(i_want): + haveing = i_have.pop(key, {}) + if wanting != haveing: + if haveing and self.state in ["overridden", "replaced"]: + self.addcmd(haveing, _parser, negate=True) + self.addcmd(wanting, _parser) + for key, haveing in iteritems(i_have): + self.addcmd(haveing, _parser, negate=True) + + def _users_list_to_dict(self, data): + """Convert all list of dicts to dicts of dicts""" + p_key = { + "enable": "level", + "users": "name", + } + tmp_data = deepcopy(data) + for k, _v in p_key.items(): + if k in tmp_data: + if k == "enable": + tmp_data[k] = {str(i.get(_v, 15)): i for i in tmp_data[k]} + else: + tmp_data[k] = {str(i[_v]): i for i in tmp_data[k]} + return tmp_data diff --git a/plugins/module_utils/network/ios/facts/facts.py b/plugins/module_utils/network/ios/facts/facts.py index 6a28043bf6..85417b48b7 100644 --- a/plugins/module_utils/network/ios/facts/facts.py +++ b/plugins/module_utils/network/ios/facts/facts.py @@ -90,6 +90,9 @@ from ansible_collections.cisco.ios.plugins.module_utils.network.ios.facts.static_routes.static_routes import ( Static_routesFacts, ) +from ansible_collections.cisco.ios.plugins.module_utils.network.ios.facts.user_global.user_global import ( + User_globalFacts, +) from ansible_collections.cisco.ios.plugins.module_utils.network.ios.facts.vlans.vlans import ( VlansFacts, ) @@ -122,6 +125,7 @@ service=ServiceFacts, snmp_server=Snmp_serverFacts, hostname=HostnameFacts, + user_global=User_globalFacts, ) diff --git a/plugins/module_utils/network/ios/facts/user_global/__init__.py b/plugins/module_utils/network/ios/facts/user_global/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/module_utils/network/ios/facts/user_global/user_global.py b/plugins/module_utils/network/ios/facts/user_global/user_global.py new file mode 100644 index 0000000000..95f64b6131 --- /dev/null +++ b/plugins/module_utils/network/ios/facts/user_global/user_global.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# Copyright 2023 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +""" +The ios user_global fact class +It is in this file the configuration is collected from the device +for a given resource, parsed, and the facts tree is populated +based on the configuration. +""" + +from ansible_collections.ansible.netcommon.plugins.module_utils.network.common import utils + +from ansible_collections.cisco.ios.plugins.module_utils.network.ios.argspec.user_global.user_global import ( + User_globalArgs, +) +from ansible_collections.cisco.ios.plugins.module_utils.network.ios.rm_templates.user_global import ( + User_globalTemplate, +) + + +class User_globalFacts(object): + """The ios user_global facts class""" + + def __init__(self, module, subspec="config", options="options"): + self._module = module + self.argument_spec = User_globalArgs.argument_spec + + def get_users_data(self, connection): + return connection.get("show running-config | section ^username|^enable") + + def sort_list_dicts(self, objs): + p_key = { + "enable": "level", + "users": "name", + } + for k, _v in p_key.items(): + if k in objs: + if k == "enable": + objs[k] = sorted(objs[k], key=lambda _k: str(_k.get(_v, 15))) + else: + objs[k] = sorted(objs[k], key=lambda _k: str(_k[_v])) + return objs + + def populate_facts(self, connection, ansible_facts, data=None): + """Populate the facts for User_global network resource + + :param connection: the device connection + :param ansible_facts: Facts dictionary + :param data: previously collected conf + + :rtype: dictionary + :returns: facts + """ + facts = {} + objs = [] + params = {} + + if not data: + data = self.get_users_data(connection) + + # parse native config using the User_global template + user_global_parser = User_globalTemplate(lines=data.splitlines(), module=self._module) + objs = user_global_parser.parse() + + if objs: + self.sort_list_dicts(objs) + + ansible_facts["ansible_network_resources"].pop("user_global", None) + + params = utils.remove_empties( + user_global_parser.validate_config(self.argument_spec, {"config": objs}, redact=True), + ) + + facts["user_global"] = params["config"] + ansible_facts["ansible_network_resources"].update(facts) + + return ansible_facts diff --git a/plugins/module_utils/network/ios/rm_templates/user_global.py b/plugins/module_utils/network/ios/rm_templates/user_global.py new file mode 100644 index 0000000000..5177e708c7 --- /dev/null +++ b/plugins/module_utils/network/ios/rm_templates/user_global.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# Copyright 2023 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +""" +The User_global parser templates file. This contains +a list of parser definitions and associated functions that +facilitates both facts gathering and native command generation for +the given network resource. +""" + +import re + +from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.rm_base.network_template import ( + NetworkTemplate, +) + + +class User_globalTemplate(NetworkTemplate): + def __init__(self, lines=None, module=None): + super(User_globalTemplate, self).__init__(lines=lines, tmplt=self, module=module) + + # fmt: off + PARSERS = [ + { + "name": "enable", + "getval": re.compile( + r""" + ^enable + (\s(?Ppassword|secret))? + (\slevel\s(?P\d+))? + (\s(?P[56789]))? + (\s(?P\S+))? + """, re.VERBOSE, + ), + "setval": "enable" + "{% if 'type' in password and password.type == 'password' %}" + "{{ ' password' }}" + "{{ ' level ' + level|string if level is defined and 1 <= level|int <= 14 else '' }}" + "{{ ' ' + password.hash|string if 'hash' in password and password.hash|int in [ 0, 6, 7 ] else ' 0' }}" + "{{ ' ' + password.value }}" + "{% else %}" + "{{ ' secret' }}" + "{{ ' level ' + level|string if level is defined and 1 <= level|int <= 14 else '' }}" + "{{ ' ' + password.hash|string if 'hash' in password and password.hash|int in [ 0, 5, 8, 9 ] else ' 0' }}" + "{{ ' ' + password.value }}" + "{% endif %}", + "result": { + "enable": [ + { + "level": "{{ level }}", + "password": { + "type": "{{ type }}", + "hash": "{{ hash }}", + "value": "{{ value }}", + }, + }, + ], + }, + }, + { + "name": "users", + "getval": re.compile( + r""" + ^username + (\s(?P\S+))? + (\sprivilege\s(?P\d+))? + (\s(?Ppassword|secret))? + (\s(?P[56789]))? + (\s(?P\S+))? + """, re.VERBOSE, + ), + "setval": "username {{ name }}" + "{{ ' privilege ' + privilege|string if privilege is defined and 0 <= privilege|int <= 15 and privilege|int != 1 else '' }}" + "{% if 'type' in password and password.type == 'password' %}" + "{{ ' password' }}" + "{{ ' ' + password.hash|string if 'hash' in password and password.hash|int in [ 0, 6, 7 ] else ' 0' }}" + "{{ ' ' + password.value }}" + "{% else %}" + "{{ ' secret' }}" + "{{ ' ' + password.hash|string if 'hash' in password and password.hash|int in [ 0, 5, 8, 9 ] else ' 0' }}" + "{{ ' ' + password.value }}" + "{% endif %}", + "result": { + "users": [ + { + "name": "{{ name }}", + "privilege": "{{ privilege }}", + "password": { + "type": "{{ type }}", + "hash": "{{ hash }}", + "value": "{{ value }}", + }, + }, + ], + }, + }, + ] + # fmt: on diff --git a/plugins/modules/ios_facts.py b/plugins/modules/ios_facts.py index 93b7b446a3..4fe29c2b5f 100644 --- a/plugins/modules/ios_facts.py +++ b/plugins/modules/ios_facts.py @@ -61,7 +61,7 @@ 'ntp_global', 'acls', 'hostname', 'interfaces', 'lldp_interfaces', 'logging_global', 'ospf_interfaces', 'ospfv2', 'prefix_lists', 'static_routes', 'acl_interfaces', 'all', 'bgp_address_family', 'l2_interfaces', 'lacp', 'lacp_interfaces', 'lldp_global', - 'ospfv3', 'snmp_server', 'vlans', 'service'. + 'ospfv3', 'snmp_server', 'vlans', 'service', 'user_global'. type: list elements: str available_network_resources: diff --git a/plugins/modules/ios_user_global.py b/plugins/modules/ios_user_global.py new file mode 100644 index 0000000000..5fea8724b9 --- /dev/null +++ b/plugins/modules/ios_user_global.py @@ -0,0 +1,476 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright 2023 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +The module file for ios_user_global +""" + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = """ +module: ios_user_global +short_description: Resource module to configure user and enable +description: + - This module provides declarative management of user and enable on Cisco IOS devices +version_added: 4.7.0 +author: + - Ambroise Rosset (@earendilfr) +notes: + - Tested against Cisco IOSXE Version 17.3 on CML. + - This module works with connection C(network_cli). +options: + config: + description: A dictionary of user and enable options + type: dict + suboptions: + enable: + description: A dictionary of options for enable password + elements: dict + suboptions: + password: + description: + - Define the password or secret for enable role (MAX of 25 characters) + type: dict + suboptions: + type: + description: + - Choose the type of password + - I(password) for the old reversible algorythn + - I(secret) to use the more recent and secure algorithm + choices: + - password + - secret + default: secret + type: str + hash: + description: + - Specifies the type of hash used in password provided + - C(0) - UNENCRYPTED password (I(Default)) + - C(5) - MD5 HASHED secret + - C(6) - ENCRYPTED password (require a crypto-key on device) + - C(7) - HIDDEN password + - C(8) - PBKDF2 HASHED secret + - C(9) - SCRYPT HASHED secret + choices: [ 0, 5, 6, 7, 8, 9 ] + default: 0 + type: int + value: + description: + - The actual hashed password to be configured on the device + - The password should be complient with the C(hash) parameter choose + previously + type: str + required: true + level: + description: + - Set exec level password + - The I(level) valu should be between C(1) and C(15) + type: int + type: list + users: + description: Define a user + elements: dict + suboptions: + name: + description: + - The name of the user + type: str + required: true + description: + description: + - Add description to MAC user (MAX of 128 characters) + type: str + password: + description: + - Define the password or secret for user (MAX of 25 characters) + type: dict + suboptions: + type: + description: + - Choose the type of password + - I(password) for the old reversible algorythn + - I(secret) to use the more recent and secure algorithm + choices: + - password + - secret + default: secret + type: str + hash: + description: + - Specifies the type of hash used in password provided + - C(0) - UNENCRYPTED password (I(Default)) + - C(5) - MD5 HASHED secret + - C(6) - ENCRYPTED password (require a crypto-key on device) + - C(7) - HIDDEN password + - C(8) - PBKDF2 HASHED secret + - C(9) - SCRYPT HASHED secret + choices: [ 0, 5, 6, 7, 8, 9 ] + default: 0 + type: int + value: + description: + - The actual hashed password to be configured on the device + - The password should be complient with the C(hash) parameter choose + previously + type: str + required: true + privilege: + description: + - Set user privilege level + - The I(privilege) value should be between C(1) and C(15) + type: int + type: list + running_config: + description: + - This option is used only with state I(parsed). + - The value of this option should be the output received from the IOS device + by executing the command B(show running-config | section ^username|^enable). + - The state I(parsed) reads the configuration from C(running_config) option and + transforms it into Ansible structured data as per the resource module's argspec + and the value is then returned in the I(parsed) key within the result. + type: str + state: + choices: + - merged + - overridden + - deleted + - rendered + - gathered + - parsed + default: merged + description: + - The state the configuration should be left in + - The states I(rendered), I(gathered) and I(parsed) does not perform any change + on the device. + - The state I(rendered) will transform the configuration in C(config) option to + platform specific CLI commands which will be returned in the I(rendered) key + within the result. For state I(rendered) active connection to remote host is + not required. + - The state I(gathered) will fetch the running configuration from device and transform + it into structured data in the format as per the resource module argspec and + the value is returned in the I(gathered) key within the result. + - The state I(parsed) reads the configuration from C(running_config) option and + transforms it into JSON format as per the resource module parameters and the + value is returned in the I(parsed) key within the result. The value of C(running_config) + option should be the same format as the output of command + I(show running-config | section ^username|^enable) executed on device. For state I(parsed) + active connection to remote host is not required. + type: str +""" + +EXAMPLES = """ +# Using state: merged + +# Before state: +# ------------- + +# router-ios#show running-config | section ^username|^enable +# --------------------- EMPTY ----------------- + +# Merged play: +# ------------ + +- name: Apply the provided configuration + cisco.ios.ios_user_global: + config: + enable: + - password: + hash: 0 + type: myenablepassword + users: + - name: johndoe + privilege: 15 + password: + hash: 0 + type: johndoepwd + state: merged + +# Commands Fired: +# --------------- + +# "commands": [ +# "enable secret 0 myenablepassword", +# "username johndoe privilege 15 secret 0 johndoepwd", +# ], + +# After state: +# ------------ + +# router-ios#show running-config | section ^username|^enable +# enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo +# username johndoe privilege 15 secret 9 $9$mUv1uo8NTi0u/U$1bu/NzyGL37xR0oLq0hCWXE1tVlZ97BILpo9aswAykQ + +# Using state: deleted + +# router-ios#show running-config | section ^username|^enable +# enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo +# username admin secret 9 $9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg +# username johndoe privilege 15 secret 9 $9$mUv1uo8NTi0u/U$1bu/NzyGL37xR0oLq0hCWXE1tVlZ97BILpo9aswAykQ + +# Deleted play: +# ------------- + +- name: Remove all existing configuration + cisco.ios.ios_user_global: + state: deleted + +# Commands Fired: +# --------------- + +# "commands": [ +# "no enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo", +# "no username admin secret 9 $9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", +# "no username johndoe privilege 15 secret 9 $9$mUv1uo8NTi0u/U$1bu/NzyGL37xR0oLq0hCWXE1tVlZ97BILpo9aswAykQ", +# ] + +# After state: +# ------------ + +# router-ios#show running-config | section ^username|^enable +# --------------------- EMPTY ----------------- + +# Using state: replaced + +# Before state: +# ------------- + +# router-ios#show running-config | section ^username|^enable +# enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo +# username johndoe privilege 15 secret 9 $9$mUv1uo8NTi0u/U$1bu/NzyGL37xR0oLq0hCWXE1tVlZ97BILpo9aswAykQ + +# Overridden play: +# -------------- + +- name: Override commands with provided configuration + cisco.ios.ios_user_global: + config: + enable: + - password: + type: secret + hash: 9 + value: "$9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo" + users: + - name: admin + privilege: 15 + password: + type: secret + hash: 9 + value: "$9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg" + state: overridden + +# Commands Fired: +# --------------- +# "commands": [ +# "no username johndoe privilege 15 secret 9 $9$mUv1uo8NTi0u/U$1bu/NzyGL37xR0oLq0hCWXE1tVlZ97BILpo9aswAykQ", +# "username admin secret 9 $9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", +# ] + +# After state: +# ------------ + +# router-ios#show running-config | section ^username|^enable +# enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo +# username admin secret 9 $9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg + +# Using state: gathered + +# Before state: +# ------------- + +# router-ios#show running-config | section ^username|^enable +# enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo +# username admin secret 9 $9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg + +# Gathered play: +# -------------- + +- name: Gather listed snmp config + cisco.ios.ios_user_global: + state: gathered + +# Module Execution Result: +# ------------------------ + +# "gathered": { +# "enable": [ +# { +# "password": { +# "type": "secret", +# "hash": 9, +# "value": "$9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo", +# }, +# }, +# ], +# "users": [ +# { +# "name": "admin", +# "password": { +# "type": "secret", +# "hash": 9, +# "value": "$9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", +# }, +# }, +# ], +# } + +# Using state: rendered + +# Rendered play: +# -------------- + +- name: Render the commands for provided configuration + cisco.ios.ios_user_global: + config: + enable: + - password: + type: secret + hash: 9 + value: "$9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo" + users: + - name: admin + privilege: 15 + password: + type: secret + hash: 9 + value: "$9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg" + state: rendered + +# Module Execution Result: +# ------------------------ + +# "rendered": [ +# "enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo" +# "username admin privilege 15 secret 9 $9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", +# ] + +# Using state: parsed + +# File: parsed.cfg +# ---------------- + +# enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo +# username admin privilege 15 secret 9 $9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg + +# Parsed play: +# ------------ + +- name: Parse the provided configuration with the existing running configuration + cisco.ios.ios_user_global: + running_config: "{{ lookup('file', 'parsed.cfg') }}" + state: parsed + +# Module Execution Result: +# ------------------------ + +# "parsed": { +# "enable": [ +# { +# "password": { +# "type": "secret", +# "hash": 9, +# "value": "$9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo", +# }, +# }, +# ], +# "users": [ +# { +# "name": "admin", +# "privilege": 15, +# "password": { +# "type": "secret", +# "hash": 9, +# "value: "$9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", +# }, +# }, +# ], +# } +""" + +RETURN = """ +before: + description: The configuration prior to the module execution. + returned: when I(state) is C(merged), C(replaced), C(overridden), C(deleted) or C(purged) + type: dict + sample: > + This output will always be in the same format as the + module argspec. +after: + description: The resulting configuration after module execution. + returned: when changed + type: dict + sample: > + This output will always be in the same format as the + module argspec. +commands: + description: The set of commands pushed to the remote device. + returned: when I(state) is C(merged), C(replaced), C(overridden), C(deleted) or C(purged) + type: list + sample: + - sample command 1 + - sample command 2 + - sample command 3 +rendered: + description: The provided configuration in the task rendered in device-native format (offline). + returned: when I(state) is C(rendered) + type: list + sample: + - sample command 1 + - sample command 2 + - sample command 3 +gathered: + description: Facts about the network resource gathered from the remote device as structured data. + returned: when I(state) is C(gathered) + type: list + sample: > + This output will always be in the same format as the + module argspec. +parsed: + description: The device native config provided in I(running_config) option parsed into structured data as per module argspec. + returned: when I(state) is C(parsed) + type: list + sample: > + This output will always be in the same format as the + module argspec. +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.cisco.ios.plugins.module_utils.network.ios.argspec.user_global.user_global import ( + User_globalArgs, +) +from ansible_collections.cisco.ios.plugins.module_utils.network.ios.config.user_global.user_global import ( + User_global, +) + + +def main(): + """ + Main entry point for module execution + + :returns: the result form module invocation + """ + module = AnsibleModule( + argument_spec=User_globalArgs.argument_spec, + mutually_exclusive=[["config", "running_config"]], + required_if=[ + ["state", "merged", ["config"]], + ["state", "replaced", ["config"]], + ["state", "overridden", ["config"]], + ["state", "rendered", ["config"]], + ["state", "parsed", ["running_config"]], + ], + supports_check_mode=True, + ) + + result = User_global(module).execute_module() + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/modules/network/ios/fixtures/ios_user_global_config.cfg b/tests/unit/modules/network/ios/fixtures/ios_user_global_config.cfg new file mode 100644 index 0000000000..8e152dde1e --- /dev/null +++ b/tests/unit/modules/network/ios/fixtures/ios_user_global_config.cfg @@ -0,0 +1 @@ +username johndoe secret 5 $5$cAYu$0he5yPyPAbXoXo6U0fjzb4NbLLyqDRehwQU3ysKEC33 diff --git a/tests/unit/modules/network/ios/test_ios_user_global.py b/tests/unit/modules/network/ios/test_ios_user_global.py new file mode 100644 index 0000000000..cf83fe03de --- /dev/null +++ b/tests/unit/modules/network/ios/test_ios_user_global.py @@ -0,0 +1,330 @@ +# (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 + +from textwrap import dedent + +from ansible_collections.cisco.ios.plugins.modules import ios_user_global +from ansible_collections.cisco.ios.tests.unit.compat.mock import patch +from ansible_collections.cisco.ios.tests.unit.modules.utils import set_module_args + +from .ios_module import TestIosModule + + +class TestIosUserGlobalModule(TestIosModule): + module = ios_user_global + + def setUp(self): + super(TestIosUserGlobalModule, self).setUp() + self.mock_get_resource_connection_facts = patch( + "ansible_collections.ansible.netcommon.plugins.module_utils.network.common.rm_base.resource_module_base." + "get_resource_connection", + ) + self.get_resource_connection_facts = self.mock_get_resource_connection_facts.start() + + self.mock_execute_show_command = patch( + "ansible_collections.cisco.ios.plugins.module_utils.network.ios.facts.user_global.user_global." + "User_globalFacts.get_users_data", + ) + self.execute_show_command = self.mock_execute_show_command.start() + + def tearDown(self): + super(TestIosUserGlobalModule, self).tearDown() + self.mock_get_resource_connection_facts.stop() + self.mock_execute_show_command.stop() + + def test_ios_user_global_merged_idempotent(self): + self.execute_show_command.return_value = dedent( + """\ + enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo + username admin secret 9 $9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg + """, + ) + + playbook = { + "config": { + "enable": [ + { + "password": { + "type": "secret", + "hash": 9, + "value": "$9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo", + }, + }, + ], + "users": [ + { + "name": "admin", + "password": { + "type": "secret", + "hash": 9, + "value": "$9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", + }, + }, + ], + }, + } + merged = [] + playbook["state"] = "merged" + set_module_args(playbook) + result = self.execute_module() + + self.assertEqual(sorted(result["commands"]), sorted(merged)) + + def test_ios_user_global_merged(self): + self.execute_show_command.return_value = dedent( + """\ + username johndoe secret 5 $5$cAYu$0he5yPyPAbXoXo6U0fjzb4NbLLyqDRehwQU3ysKEC33 + """, + ) + + playbook = { + "config": { + "enable": [ + { + "password": { + "type": "secret", + "hash": 9, + "value": "$9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo", + }, + }, + ], + "users": [ + { + "name": "admin", + "password": { + "type": "secret", + "hash": 9, + "value": "$9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", + }, + }, + ], + }, + } + merged = [ + "enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo", + "username admin secret 9 $9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", + ] + playbook["state"] = "merged" + set_module_args(playbook) + result = self.execute_module(changed=True) + + self.assertEqual(sorted(result["commands"]), sorted(merged)) + + def test_ios_user_global_deleted(self): + self.execute_show_command.return_value = dedent( + """\ + username johndoe secret 5 $5$cAYu$0he5yPyPAbXoXo6U0fjzb4NbLLyqDRehwQU3ysKEC33 + """, + ) + playbook = {"config": {}} + deleted = [ + "no username johndoe secret 5 $5$cAYu$0he5yPyPAbXoXo6U0fjzb4NbLLyqDRehwQU3ysKEC33", + ] + playbook["state"] = "deleted" + set_module_args(playbook) + self.maxDiff = None + result = self.execute_module(changed=True) + self.assertEqual(sorted(result["commands"]), sorted(deleted)) + + def test_ios_user_global_overridden(self): + self.execute_show_command.return_value = dedent( + """\ + username johndoe secret 5 $5$cAYu$0he5yPyPAbXoXo6U0fjzb4NbLLyqDRehwQU3ysKEC33 + """, + ) + + playbook = { + "config": { + "enable": [ + { + "password": { + "type": "secret", + "hash": 9, + "value": "$9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo", + }, + }, + ], + "users": [ + { + "name": "admin", + "password": { + "type": "secret", + "hash": 9, + "value": "$9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", + }, + }, + ], + }, + } + overridden = [ + "enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo", + "no username johndoe secret 5 $5$cAYu$0he5yPyPAbXoXo6U0fjzb4NbLLyqDRehwQU3ysKEC33", + "username admin secret 9 $9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", + ] + playbook["state"] = "overridden" + set_module_args(playbook) + result = self.execute_module(changed=True) + self.assertEqual(sorted(result["commands"]), sorted(overridden)) + + def test_ios_user_global_overridden_idempotent(self): + self.execute_show_command.return_value = dedent( + """\ + enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo + username admin secret 9 $9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg + """, + ) + + playbook = { + "config": { + "enable": [ + { + "password": { + "type": "secret", + "hash": 9, + "value": "$9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo", + }, + }, + ], + "users": [ + { + "name": "admin", + "password": { + "type": "secret", + "hash": 9, + "value": "$9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", + }, + }, + ], + }, + } + overridden = [] + playbook["state"] = "overridden" + set_module_args(playbook) + result = self.execute_module(changed=False) + self.maxDiff = None + self.assertEqual(sorted(result["commands"]), sorted(overridden)) + + def test_ios_user_global_parsed(self): + set_module_args( + dict( + running_config=dedent( + """\ + enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo + username admin secret 9 $9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg + """, + ), + state="parsed", + ), + ) + parsed = { + "enable": [ + { + "password": { + "type": "secret", + "hash": 9, + "value": "$9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo", + }, + }, + ], + "users": [ + { + "name": "admin", + "password": { + "type": "secret", + "hash": 9, + "value": "$9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", + }, + }, + ], + } + result = self.execute_module(changed=False) + self.maxDiff = None + self.assertEqual(result["parsed"], parsed) + + def test_ios_user_global_gathered(self): + self.execute_show_command.return_value = dedent( + """\ + enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo + username admin secret 9 $9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg + """, + ) + set_module_args(dict(state="gathered")) + gathered = { + "enable": [ + { + "password": { + "type": "secret", + "hash": 9, + "value": "$9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo", + }, + }, + ], + "users": [ + { + "name": "admin", + "password": { + "type": "secret", + "hash": 9, + "value": "$9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", + }, + }, + ], + } + result = self.execute_module(changed=False) + self.maxDiff = None + self.assertEqual(sorted(result["gathered"]), sorted(gathered)) + + def test_ios_user_global_rendered(self): + set_module_args( + { + "config": { + "enable": [ + { + "password": { + "type": "secret", + "hash": 9, + "value": "$9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo", + }, + }, + ], + "users": [ + { + "name": "admin", + "password": { + "type": "secret", + "hash": 9, + "value": "$9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", + }, + }, + ], + }, + "state": "rendered", + }, + ) + rendered = [ + "enable secret 9 $9$q3zuC3f3vjWnWk$4BwPgPt25AUkm8Gts6aqW.NLK/90zBDnmWtOeMQqoDo", + "username admin secret 9 $9$oV7t.SyAkhiemE$D7GYIpVS/IOc0c15ev/n3p4Wo509XwQpPfyL1fuC5Dg", + ] + result = self.execute_module(changed=False) + self.maxDiff = None + self.assertEqual(sorted(result["rendered"]), sorted(rendered))