Repository: ansible-collections/community.general

Issue:
community.general.imc_rest "Maximum sessions reached" error after 4 calls
**OVERVIEW**

The imc_rest module doesn't clean up its session after each successful call to the UCS so after 4 tasks it won't be able to connect because there are too many session still opened on the CIMC server.
After 4 successful tasks, checking the sessions on the UCS: 
```
# show user-session
ID     Name             IP Address        Type         Killable
------ ---------------- ----------------- ------------ --------
25     admin            10.10.10.1       xmlapi       yes
24     admin            10.10.10.1       xmlapi       yes
23     admin            10.10.10.1       xmlapi       yes
22     admin            10.10.10.1       xmlapi       yes
21     admin            10.10.10.1       CLI          no
```

After a successful call, logout is not performed and session stays alive until timeout. 

I know that you can perform many operations in the same call however the playbook logic requires breaking them down in atomic operations

**SUMMARY**

After a successful call, logout is not performed and session stays alive until timeout leading to reaching maximum number of sessions (4). After that all following calls will fail. 

**ISSUE TYPE**

Bug Report

**COMPONENT NAME**

imc_rest

**ANSIBLE VERSION**

<!--- Paste verbatim output from "ansible --version" between quotes -->
```
ansible 2.9.17
  config file = /tmp/ansible/imc-ansible/ansible.cfg
  configured module search path = [u'/root/.ansible/plugins/modules', u'/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python2.7/site-packages/ansible
  executable location = /usr/bin/ansible
  python version = 2.7.5 (default, Nov 16 2020, 22:23:17) [GCC 4.8.5 20150623 (Red Hat 4.8.5-44)]
```


**CONFIGURATION**

<!--- Paste verbatim output from "ansible-config dump --only-changed" between quotes -->
```
[root@docker-desktop imc-ansible]# ansible-config dump --only-changed
/usr/lib/python2.7/site-packages/ansible/parsing/vault/__init__.py:44: CryptographyDeprecationWarning: Python 2 is no longer supported by the Python core team. Support for it is now deprecated in cryptography, and will be removed in the next release.
  from cryptography.exceptions import InvalidSignature
ACTION_WARNINGS(/tmp/ansible/imc-ansible/ansible.cfg) = False
ANSIBLE_FORCE_COLOR(/tmp/ansible/imc-ansible/ansible.cfg) = True
COMMAND_WARNINGS(/tmp/ansible/imc-ansible/ansible.cfg) = False
DEFAULT_BECOME_FLAGS(env: ANSIBLE_BECOME_FLAGS) = -H -S -n -E
DEFAULT_CALLBACK_WHITELIST(/tmp/ansible/imc-ansible/ansible.cfg) = [u'timer', u'profile_roles']
DEFAULT_FORKS(/tmp/ansible/imc-ansible/ansible.cfg) = 50
DEFAULT_MODULE_UTILS_PATH(/tmp/ansible/imc-ansible/ansible.cfg) = [u'/usr/lib/python2.7/site-packages/ansibl
DEFAULT_PRIVATE_KEY_FILE(/tmp/ansible/imc-ansible/ansible.cfg) = /tmp/ansible/imc-ansible/.ssh/id_rsa
DEFAULT_REMOTE_USER(/tmp/ansible/imc-ansible/ansible.cfg) = admin
DEFAULT_STDOUT_CALLBACK(/tmp/ansible/imc-ansible/ansible.cfg) = yaml
DEPRECATION_WARNINGS(/tmp/ansible/imc-ansible/ansible.cfg) = False
HOST_KEY_CHECKING(/tmp/ansible/imc-ansible/ansible.cfg) = False
SYSTEM_WARNINGS(/tmp/ansible/imc-ansible/ansible.cfg) = False
```



**OS / ENVIRONMENT**

UCS C-Servers 

**STEPS TO REPRO**

<!--- Paste verbatim output from "ansible-config dump --only-changed" between quotes -->
```
[root@docker-desktop imc-ansible]# ansible-config dump --only-changed
/usr/lib/python2.7/site-packages/ansible/parsing/vault/__init__.py:44: CryptographyDeprecationWarning: Python 2 is no longer supported by the Python core team. Support for it is now deprecated in cryptography, and will be removed in the next release.
  from cryptography.exceptions import InvalidSignature
ACTION_WARNINGS(/tmp/ansible/imc-ansible/ansible.cfg) = False
ANSIBLE_FORCE_COLOR(/tmp/ansible/imc-ansible/ansible.cfg) = True
COMMAND_WARNINGS(/tmp/ansible/imc-ansible/ansible.cfg) = False
DEFAULT_BECOME_FLAGS(env: ANSIBLE_BECOME_FLAGS) = -H -S -n -E
DEFAULT_CALLBACK_WHITELIST(/tmp/ansible/imc-ansible/ansible.cfg) = [u'timer', u'profile_roles']
DEFAULT_FORKS(/tmp/ansible/imc-ansible/ansible.cfg) = 50
DEFAULT_MODULE_UTILS_PATH(/tmp/ansible/imc-ansible/ansible.cfg) = [u'/usr/lib/python2.7/site-packages/ansibl
DEFAULT_PRIVATE_KEY_FILE(/tmp/ansible/imc-ansible/ansible.cfg) = /tmp/ansible/imc-ansible/.ssh/id_rsa
DEFAULT_REMOTE_USER(/tmp/ansible/imc-ansible/ansible.cfg) = admin
DEFAULT_STDOUT_CALLBACK(/tmp/ansible/imc-ansible/ansible.cfg) = yaml
DEPRECATION_WARNINGS(/tmp/ansible/imc-ansible/ansible.cfg) = False
HOST_KEY_CHECKING(/tmp/ansible/imc-ansible/ansible.cfg) = False
SYSTEM_WARNINGS(/tmp/ansible/imc-ansible/ansible.cfg) = False

```

```
- name: Configure LDAP (keep disabled)
  community.general.imc_rest:
    hostname: '{{ imc_ip }}'
    username: '{{ imc_username }}'
    password: '{{ imc_password }}'
    validate_certs: no
    content: |
      <configConfMo inHierarchical="true" dn="sys/ldap-ext">
        <inConfig>
          <aaaLdap dn="sys/ldap-ext" adminState="disabled" basedn="" domain="orange.fr"
          filter="sAMAccountName" attribute="CiscoAvPair" timeout="60"
          encryption="disabled" locateDirectoryUsingDNS="yes"
          dnsDomainSource="extracted-domain"
          ldapServer1="172.26.53.36" ldapServerPort1="636"
          ldapServer2="1.2.3.4" ldapServerPort2="636"
          ldapServer3="ladap.orange.fr" ldapServerPort3="9636"
          ldapServer4="ldap2.orange.fr" ldapServerPort4="3269"
          ldapServer5="" ldapServerPort5="9269"
          ldapServer6=""  ldapServerPort6="3269"
          bindMethod="login-credentials" bindDn="" password=""
          groupAuth="disabled"  groupAttribute="memberOf"/>
        </inConfig>
      </configConfMo>
  delegate_to: localhost
  register: ldap_config

- name: Fetch LDAP config
  community.general.imc_rest:
    hostname: '{{ imc_ip }}'
    username: '{{ imc_username }}'
    password: '{{ imc_password }}'
    validate_certs: no
    content: |
      <configResolveClass classId="aaaLdap"/>
  delegate_to: localhost
  register: ldap_config_results

- name: Configure NTP
  community.general.imc_rest:
    hostname: '{{ imc_ip }}'
    username: '{{ imc_username }}'
    password: '{{ imc_password }}'
    validate_certs: no
    content: |
      <configConfMo dn="sys/svc-ext/ntp-svc">
        <inConfig>
          <commNtpProvider dn="sys/svc-ext/ntp-svc"
          ntpEnable="yes"
          ntpServer1="1.1.1.1"
          ntpServer2="2.2.2.2"
          ntpServer3="3.3.3.3"
          ntpServer4=""/>
        </inConfig>
      </configConfMo>
  delegate_to: localhost
  register: ntp_config

- name: Retrieve NTP configs
  community.general.imc_rest:
    hostname: '{{ imc_ip }}'
    username: '{{ imc_username }}'
    password: '{{ imc_password }}'
    validate_certs: no
    content: |
      <configResolveClass classId='commNtpProvider' inHierarchical='false'/>
  delegate_to: localhost
  register: ntp_config_results

```

Any additional task will fail as session limit is reached

```
# show user-session
ID     Name             IP Address        Type         Killable
------ ---------------- ----------------- ------------ --------
25     admin            10.10.10.1       xmlapi       yes
24     admin            10.10.10.1       xmlapi       yes
23     admin            10.10.10.1       xmlapi       yes
22     admin            10.10.10.1       xmlapi       yes
21     admin            10.10.10.1       CLI          no

```


**EXPECTED RESULTS**

_No response_

**ACTUAL RESULTS**

<!--- Paste verbatim command output between -->
```
New task will fail as sessions are not cleaned up

```





Discussion:
Files identified in the description:
None

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
Files identified in the description:
* [`plugins/modules/remote_management/imc/imc_rest.py`](https://github.com/ansible-collections/community.general/blob/master/plugins/modules/remote_management/imc/imc_rest.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @dagwieers
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/plugins/modules/remote_management/imc/imc_rest.py b/plugins/modules/remote_management/imc/imc_rest.py
--- a/plugins/modules/remote_management/imc/imc_rest.py
+++ b/plugins/modules/remote_management/imc/imc_rest.py
@@ -27,21 +27,25 @@
     - IP Address or hostname of Cisco IMC, resolvable by Ansible control host.
     required: true
     aliases: [ host, ip ]
+    type: str
   username:
     description:
     - Username used to login to the switch.
     default: admin
     aliases: [ user ]
+    type: str
   password:
     description:
     - The password to use for authentication.
     default: password
+    type: str
   path:
     description:
     - Name of the absolute path of the filename that includes the body
       of the http request being sent to the Cisco IMC REST API.
     - Parameter C(path) is mutual exclusive with parameter C(content).
     aliases: [ 'src', 'config_file' ]
+    type: path
   content:
     description:
     - When used instead of C(path), sets the content of the API requests directly.
@@ -49,11 +53,13 @@
     - You can collate multiple IMC XML fragments and they will be processed sequentially in a single stream,
       the Cisco IMC output is subsequently merged.
     - Parameter C(content) is mutual exclusive with parameter C(path).
+    type: str
   protocol:
     description:
     - Connection protocol to use.
     default: https
     choices: [ http, https ]
+    type: str
   timeout:
     description:
     - The socket level timeout in seconds.
@@ -61,6 +67,7 @@
       If this C(timeout) is reached, the module will fail with a
       C(Connection failure) indicating that C(The read operation timed out).
     default: 60
+    type: int
   validate_certs:
     description:
     - If C(no), SSL certificates will not be validated.
@@ -253,11 +260,11 @@
       errorDescr="XML PARSING ERROR: Element 'computeRackUnit', attribute 'admin_Power': The attribute 'admin_Power' is not allowed.\n"/>
 '''
 
-import atexit
 import datetime
 import itertools
 import os
 import traceback
+from functools import partial
 
 LXML_ETREE_IMP_ERR = None
 try:
@@ -317,7 +324,6 @@ def merge(one, two):
 
 
 def main():
-
     module = AnsibleModule(
         argument_spec=dict(
             hostname=dict(type='str', required=True, aliases=['host', 'ip']),
@@ -374,53 +380,54 @@ def main():
     result.update(imc_response(module, resp.read()))
 
     # Store cookie for future requests
+    cookie = ''
     try:
         cookie = result['aaaLogin']['attributes']['outCookie']
     except Exception:
         module.fail_json(msg='Could not find cookie in output', **result)
 
-    # If we would not log out properly, we run out of sessions quickly
-    atexit.register(logout, module, url, cookie, timeout)
-
-    # Prepare request data
-    if content:
-        rawdata = content
-    elif file_exists:
-        with open(path, 'r') as config_object:
-            rawdata = config_object.read()
-
-    # Wrap the XML documents in a <root> element
-    xmldata = lxml.etree.fromstring('<root>%s</root>' % rawdata.replace('\n', ''))
-
-    # Handle each XML document separately in the same session
-    for xmldoc in list(xmldata):
-        if xmldoc.tag is lxml.etree.Comment:
-            continue
-        # Add cookie to XML
-        xmldoc.set('cookie', cookie)
-        data = lxml.etree.tostring(xmldoc)
-
-        # Perform actual request
-        resp, info = fetch_url(module, url, data=data, method='POST', timeout=timeout)
-        if resp is None or info['status'] != 200:
-            result['elapsed'] = (datetime.datetime.utcnow() - start).seconds
-            module.fail_json(msg='Task failed with error %(status)s: %(msg)s' % info, **result)
-
-        # Merge results with previous results
-        rawoutput = resp.read()
-        result = merge(result, imc_response(module, rawoutput, rawinput=data))
-        result['response'] = info['msg']
-        result['status'] = info['status']
-
-        # Check for any changes
-        # NOTE: Unfortunately IMC API always report status as 'modified'
-        xmloutput = lxml.etree.fromstring(rawoutput)
-        results = xmloutput.xpath('/configConfMo/outConfig/*/@status')
-        result['changed'] = ('modified' in results)
-
-    # Report success
-    result['elapsed'] = (datetime.datetime.utcnow() - start).seconds
-    module.exit_json(**result)
+    try:
+        # Prepare request data
+        if content:
+            rawdata = content
+        elif file_exists:
+            with open(path, 'r') as config_object:
+                rawdata = config_object.read()
+
+        # Wrap the XML documents in a <root> element
+        xmldata = lxml.etree.fromstring('<root>%s</root>' % rawdata.replace('\n', ''))
+
+        # Handle each XML document separately in the same session
+        for xmldoc in list(xmldata):
+            if xmldoc.tag is lxml.etree.Comment:
+                continue
+            # Add cookie to XML
+            xmldoc.set('cookie', cookie)
+            data = lxml.etree.tostring(xmldoc)
+
+            # Perform actual request
+            resp, info = fetch_url(module, url, data=data, method='POST', timeout=timeout)
+            if resp is None or info['status'] != 200:
+                result['elapsed'] = (datetime.datetime.utcnow() - start).seconds
+                module.fail_json(msg='Task failed with error %(status)s: %(msg)s' % info, **result)
+
+            # Merge results with previous results
+            rawoutput = resp.read()
+            result = merge(result, imc_response(module, rawoutput, rawinput=data))
+            result['response'] = info['msg']
+            result['status'] = info['status']
+
+            # Check for any changes
+            # NOTE: Unfortunately IMC API always report status as 'modified'
+            xmloutput = lxml.etree.fromstring(rawoutput)
+            results = xmloutput.xpath('/configConfMo/outConfig/*/@status')
+            result['changed'] = ('modified' in results)
+
+        # Report success
+        result['elapsed'] = (datetime.datetime.utcnow() - start).seconds
+        module.exit_json(**result)
+    finally:
+        logout(module, url, cookie, timeout)
 
 
 if __name__ == '__main__':


Test patch:
diff --git a/tests/sanity/ignore-2.10.txt b/tests/sanity/ignore-2.10.txt
--- a/tests/sanity/ignore-2.10.txt
+++ b/tests/sanity/ignore-2.10.txt
@@ -241,7 +241,6 @@ plugins/modules/packaging/os/xbps.py validate-modules:parameter-invalid
 plugins/modules/remote_management/hpilo/hpilo_boot.py validate-modules:parameter-type-not-in-doc
 plugins/modules/remote_management/hpilo/hpilo_info.py validate-modules:parameter-type-not-in-doc
 plugins/modules/remote_management/hpilo/hponcfg.py validate-modules:parameter-type-not-in-doc
-plugins/modules/remote_management/imc/imc_rest.py validate-modules:parameter-type-not-in-doc
 plugins/modules/remote_management/lxca/lxca_cmms.py validate-modules:doc-missing-type
 plugins/modules/remote_management/lxca/lxca_nodes.py validate-modules:doc-missing-type
 plugins/modules/remote_management/manageiq/manageiq_alert_profiles.py validate-modules:parameter-list-no-elements
diff --git a/tests/sanity/ignore-2.11.txt b/tests/sanity/ignore-2.11.txt
--- a/tests/sanity/ignore-2.11.txt
+++ b/tests/sanity/ignore-2.11.txt
@@ -240,7 +240,6 @@ plugins/modules/packaging/os/xbps.py validate-modules:parameter-invalid
 plugins/modules/remote_management/hpilo/hpilo_boot.py validate-modules:parameter-type-not-in-doc
 plugins/modules/remote_management/hpilo/hpilo_info.py validate-modules:parameter-type-not-in-doc
 plugins/modules/remote_management/hpilo/hponcfg.py validate-modules:parameter-type-not-in-doc
-plugins/modules/remote_management/imc/imc_rest.py validate-modules:parameter-type-not-in-doc
 plugins/modules/remote_management/lxca/lxca_cmms.py validate-modules:doc-missing-type
 plugins/modules/remote_management/lxca/lxca_nodes.py validate-modules:doc-missing-type
 plugins/modules/remote_management/manageiq/manageiq_alert_profiles.py validate-modules:parameter-list-no-elements
diff --git a/tests/sanity/ignore-2.9.txt b/tests/sanity/ignore-2.9.txt
--- a/tests/sanity/ignore-2.9.txt
+++ b/tests/sanity/ignore-2.9.txt
@@ -206,7 +206,6 @@ plugins/modules/packaging/os/xbps.py validate-modules:parameter-invalid
 plugins/modules/remote_management/hpilo/hpilo_boot.py validate-modules:parameter-type-not-in-doc
 plugins/modules/remote_management/hpilo/hpilo_info.py validate-modules:parameter-type-not-in-doc
 plugins/modules/remote_management/hpilo/hponcfg.py validate-modules:parameter-type-not-in-doc
-plugins/modules/remote_management/imc/imc_rest.py validate-modules:parameter-type-not-in-doc
 plugins/modules/remote_management/lxca/lxca_cmms.py validate-modules:doc-missing-type
 plugins/modules/remote_management/lxca/lxca_nodes.py validate-modules:doc-missing-type
 plugins/modules/remote_management/manageiq/manageiq_provider.py validate-modules:doc-choices-do-not-match-spec