diff --git a/lib/ansible/module_utils/network/aci/aci.py b/lib/ansible/module_utils/network/aci/aci.py index 2a4a24083b..503df6831f 100644 --- a/lib/ansible/module_utils/network/aci/aci.py +++ b/lib/ansible/module_utils/network/aci/aci.py @@ -186,6 +186,14 @@ class ACIModule(object): elif self.params['password'] is not None: self.module.warn('When doing ACI signatured-based authentication, a password is not required') + def iso8601_format(self, dt): + ''' Return an ACI-compatible ISO8601 formatted time: 2123-12-12T00:00:00.000+00:00 ''' + try: + return dt.isoformat(timespec='milliseconds') + except: + tz = dt.strftime('%z') + return '%s.%03d%s:%s' % (dt.strftime('%Y-%m-%dT%H:%M:%S'), dt.microsecond / 1000, tz[:3], tz[3:]) + def define_protocol(self): ''' Set protocol based on use_ssl parameter ''' @@ -315,7 +323,6 @@ class ACIModule(object): def query(self, path): ''' Perform a query with no payload ''' - # Ensure method is set self.result['path'] = path if 'port' in self.params and self.params['port'] is not None: diff --git a/lib/ansible/modules/network/aci/aci_aaa_user.py b/lib/ansible/modules/network/aci/aci_aaa_user.py new file mode 100644 index 0000000000..16414a96a4 --- /dev/null +++ b/lib/ansible/modules/network/aci/aci_aaa_user.py @@ -0,0 +1,256 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Dag Wieers (dagwieers) +# 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 + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: aci_aaa_user +short_description: Manage AAA users (aaa:User) +description: +- Manage AAA users. +- More information from the internal APIC class I(aaa:User) at + U(https://developer.cisco.com/docs/apic-mim-ref/). +author: +- Dag Wieers (@dagwieers) +notes: +- This module is not idempotent when C(aaa_password) is being used + (even if that password was already set identically). This + appears to be an inconsistency wrt. the idempotent nature + of the APIC REST API. +requirements: + - python-dateutil +version_added: '2.5' +options: + aaa_password: + description: + - The password of the locally-authenticated user. + aaa_password_lifetime: + description: + - The lifetime of the locally-authenticated user password. + aaa_password_update_required: + description: + - Whether this account needs password update. + type: bool + aaa_user: + description: + - The name of the locally-authenticated user user to add. + aliases: [ name, user ] + clear_password_history: + description: + - Whether to clear the password history of a locally-authenticated user. + type: bool + description: + description: + - Description for the AAA user. + aliases: [ descr ] + email: + description: + - The email address of the locally-authenticated user. + enabled: + description: + - The status of the locally-authenticated user account. + type: bool + expiration: + description: + - The expiration date of the locally-authenticated user account. + expires: + description: + - Whether to enable an expiration date for the locally-authenticated user account. + type: bool + first_name: + description: + - The first name of the locally-authenticated user. + last_name: + description: + - The last name of the locally-authenticated user. + phone: + description: + - The phone number of the locally-authenticated user. + state: + description: + - Use C(present) or C(absent) for adding or removing. + - Use C(query) for listing an object or multiple objects. + choices: [ absent, present, query ] + default: present +extends_documentation_fragment: aci +''' + +EXAMPLES = r''' +- name: Add a user + aci_aaa_user: + host: apic + username: admin + password: SomeSecretPassword + aaa_user: dag + aaa_password: AnotherSecretPassword + expiration: never + expires: no + email: dag@wieers.com + phone: 1-234-555-678 + first_name: Dag + last_name: Wieers + state: present + +- name: Remove a user + aci_aaa_user: + host: apic + username: admin + password: SomeSecretPassword + aaa_user: dag + state: absent + +- name: Query a user + aci_aaa_user: + host: apic + username: admin + password: SomeSecretPassword + aaa_user: dag + state: query + +- name: Query all users + aci_aaa_user: + host: apic + username: admin + password: SomeSecretPassword + state: query +''' + +RETURN = r''' # ''' + +from ansible.module_utils.network.aci.aci import ACIModule, aci_argument_spec +from ansible.module_utils.basic import AnsibleModule + +try: + from dateutil.tz import tzutc + import dateutil.parser + HAS_DATEUTIL = True +except ImportError: + HAS_DATEUTIL = False + + +def main(): + argument_spec = aci_argument_spec() + argument_spec.update( + aaa_password=dict(type='str', no_log=True), + aaa_password_lifetime=dict(type='int'), + aaa_password_update_required=dict(type='bool'), + aaa_user=dict(type='str', required=True, aliases=['name']), + clear_password_history=dict(type='bool'), + description=dict(type='str', aliases=['descr']), + email=dict(type='str'), + enabled=dict(type='bool'), + expiration=dict(type='str'), + expires=dict(type='bool'), + first_name=dict(type='str'), + last_name=dict(type='str'), + phone=dict(type='str'), + state=dict(type='str', default='present', choices=['absent', 'present', 'query']), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + ['state', 'absent', ['aaa_user']], + ['state', 'present', ['aaa_user']], + ['expires', True, ['expiration']], + ], + ) + + if not HAS_DATEUTIL: + module.fail_json(msg='dateutil required for this module') + + aaa_password = module.params['aaa_password'] + aaa_password_lifetime = module.params['aaa_password_lifetime'] + aaa_password_update_required = module.params['aaa_password_update_required'] + aaa_user = module.params['aaa_user'] + clear_password_history = module.params['clear_password_history'] + description = module.params['description'] + email = module.params['email'] + enabled = module.params['enabled'] + first_name = module.params['first_name'] + last_name = module.params['last_name'] + phone = module.params['phone'] + state = module.params['state'] + + aci = ACIModule(module) + + if module.params['enabled'] is True: + enabled = 'active' + elif module.params['enabled'] is False: + enabled = 'inactive' + else: + enabled = None + + expiration = module.params['expiration'] + if expiration is not None and expiration != 'never': + try: + expiration = aci.iso8601_format(dateutil.parser.parse(expiration).replace(tzinfo=tzutc())) + except Exception as e: + module.fail_json(msg="Failed to parse date format '%s', %s" % (module.params['expiration'], e)) + + expires = module.params['expires'] + if expires is True: + expires = 'yes' + elif expires is False: + expires = 'no' + + aaa_password_update_required = module.params['aaa_password_update_required'] + if aaa_password_update_required is True: + aaa_password_update_required = 'yes' + elif aaa_password_update_required is False: + aaa_password_update_required = 'no' + + aci.construct_url( + root_class=dict( + aci_class='aaaUser', + aci_rn='userext/user-{0}'.format(aaa_user), + filter_target='eq(aaaUser.name, "{0}")'.format(aaa_user), + module_object=aaa_user, + ), + ) + aci.get_existing() + + if state == 'present': + # Filter out module params with null values + aci.payload( + aci_class='aaaUser', + class_config=dict( + accountStatus=enabled, + clearPwdHistory=clear_password_history, + email=email, + expiration=expiration, + expires=expires, + firstName=first_name, + lastName=last_name, + name=aaa_user, + phone=phone, + pwd=aaa_password, + pwdLifeTime=aaa_password_lifetime, + pwdUpdateRequired=aaa_password_update_required, + ), + ) + + # Generate config diff which will be used as POST request body + aci.get_diff(aci_class='aaaUser') + + # Submit changes if module not in check_mode and the proposed is different than existing + aci.post_config() + + elif state == 'absent': + aci.delete_config() + + module.exit_json(**aci.result) + + +if __name__ == "__main__": + main() diff --git a/test/integration/targets/aci_aaa_user/aliases b/test/integration/targets/aci_aaa_user/aliases new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/integration/targets/aci_aaa_user/tasks/main.yml b/test/integration/targets/aci_aaa_user/tasks/main.yml new file mode 100644 index 0000000000..b6d2242018 --- /dev/null +++ b/test/integration/targets/aci_aaa_user/tasks/main.yml @@ -0,0 +1,175 @@ +# Test code for the ACI modules +# Copyright: (c) 2017, Dag Wieers (dagwieers) +# +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Test that we have an ACI APIC host, ACI username and ACI password + fail: + msg: 'Please define the following variables: aci_hostname, aci_username and aci_password.' + when: aci_hostname is not defined or aci_username is not defined or aci_password is not defined + + +# CLEAN ENVIRONMENT +- name: Remove any pre-existing user + aci_aaa_user: &user_absent + host: '{{ aci_hostname }}' + username: '{{ aci_username }}' + password: '{{ aci_password }}' + validate_certs: '{{ aci_validate_certs | default(false) }}' + use_ssl: '{{ aci_use_ssl | default(true) }}' + use_proxy: '{{ aci_use_proxy | default(true) }}' + aaa_user: ansible + state: absent + + +# ADD USER +- name: Add user (check_mode) + aci_aaa_user: &user_present + host: '{{ aci_hostname }}' + username: '{{ aci_username }}' + password: '{{ aci_password }}' + validate_certs: '{{ aci_validate_certs | default(false) }}' + use_ssl: '{{ aci_use_ssl | default(true) }}' + use_proxy: '{{ aci_use_proxy | default(true) }}' + aaa_user: ansible + description: Ansible test user + email: ansible@ansible.lan + enabled: yes + expiration: never + expires: no + first_name: Test + last_name: User + phone: 1-234-555-678 + check_mode: yes + register: cm_add_user + +# NOTE: Setting password is not idempotent, see #35544 +- name: Add user (normal mode) + aci_aaa_user: + <<: *user_present + aaa_password: 12!Ab:cD!34 + register: nm_add_user + +- name: Add user again (check mode) + aci_aaa_user: *user_present + check_mode: yes + register: cm_add_user_again + +- name: Add user again (normal mode) + aci_aaa_user: *user_present + register: nm_add_user_again + +- name: Verify add user + assert: + that: + - cm_add_user.changed == nm_add_user.changed == true + - cm_add_user_again.changed == nm_add_user_again.changed == false + + +# MODIFY USER +- name: Modify user (check_mode) + aci_aaa_user: &user_changed + host: '{{ aci_hostname }}' + username: '{{ aci_username }}' + password: '{{ aci_password }}' + validate_certs: '{{ aci_validate_certs | default(false) }}' + use_ssl: '{{ aci_use_ssl | default(true) }}' + use_proxy: '{{ aci_use_proxy | default(true) }}' + aaa_user: ansible + description: Ansible test user for integration tests + email: aci-ansible@ansible.lan + expiration: '2123-12-12' + expires: yes + phone: 2-345-555-678 + check_mode: yes + register: cm_modify_user + +- name: Modify user (normal mode) + aci_aaa_user: *user_changed + register: nm_modify_user + +- name: Modify user again (check mode) + aci_aaa_user: *user_changed + check_mode: yes + register: cm_modify_user_again + +- name: Modify user again (normal mode) + aci_aaa_user: *user_changed + register: nm_modify_user_again + +- name: Verify modify user + assert: + that: + - cm_modify_user.changed == nm_modify_user.changed == true + - cm_modify_user_again.changed == nm_modify_user_again.changed == false + + +# QUERY ALL USERS +- name: Query all users (check_mode) + aci_aaa_user: &user_query + host: '{{ aci_hostname }}' + username: '{{ aci_username }}' + password: '{{ aci_password }}' + validate_certs: '{{ aci_validate_certs | default(false) }}' + use_ssl: '{{ aci_use_ssl | default(true) }}' + use_proxy: '{{ aci_use_proxy | default(true) }}' + aaa_user: ansible + state: query + check_mode: yes + register: cm_query_all_users + +- name: Query all users (normal mode) + aci_aaa_user: *user_query + register: nm_query_all_users + +- name: Verify query_all_users + assert: + that: + - cm_query_all_users.changed == nm_query_all_users.changed == false + # NOTE: Order of users is not stable between calls + #- cm_query_all_users == nm_query_all_users + + +# QUERY OUR USER +- name: Query our user (check_mode) + aci_aaa_user: + <<: *user_query + check_mode: yes + register: cm_query_user + +- name: Query our user (normal mode) + aci_aaa_user: + <<: *user_query + register: nm_query_user + +- name: Verify query_user + assert: + that: + - cm_query_user.changed == nm_query_user.changed == false + - cm_query_user == nm_query_user + + +# REMOVE USER +- name: Remove user (check_mode) + aci_aaa_user: *user_absent + check_mode: yes + register: cm_remove_user + +- name: Remove user (normal mode) + aci_aaa_user: *user_absent + register: nm_remove_user + +- name: Remove user again (check_mode) + aci_aaa_user: *user_absent + check_mode: yes + register: cm_remove_user_again + +- name: Remove user again (normal mode) + aci_aaa_user: *user_absent + register: nm_remove_user_again + +- name: Verify remove_user + assert: + that: + - cm_remove_user.changed == nm_remove_user.changed == true + - cm_remove_user_again.changed == nm_remove_user_again.changed == false