Repository: ansible-collections/community.general

Issue:
ldap_entry: recursive deletion
### Summary

I would like to delete a whole branch of my ldap tree with `ldap_entry` but it seems recursive deletions are not supported.

I suggest handling recursive deletions.

### Issue Type

Feature Idea

### Component Name

ldap_entry

### Additional Information

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)
- name: delete all the users
  ldap_entry:
    server_uri: "{{ ldap_api_url }}"
    bind_dn: "{{ ldap_bind_id }}"
    bind_pw: "{{ ldap_admin_password }}"
    dn: "{{ ldap_users_base }}"
    state: absent
```
```python
The full traceback is:
Traceback (most recent call last):
  File "master:~/.ansible/collections/ansible_collections/community/general/plugins/modules/ldap_entry.py", line 229, in main
  File "master:~/.ansible/collections/ansible_collections/community/general/plugins/modules/ldap_entry.py", line 166, in _delete
  File "/usr/lib/python3/dist-packages/ldap/ldapobject.py", line 560, in delete_s
    return self.delete_ext_s(dn,None,None)
  File "/usr/lib/python3/dist-packages/ldap/ldapobject.py", line 553, in delete_ext_s
    resp_type, resp_data, resp_msgid, resp_ctrls = self.result3(msgid,all=1,timeout=self.timeout)
  File "/usr/lib/python3/dist-packages/ldap/ldapobject.py", line 748, in result3
    resp_type, resp_data, resp_msgid, decoded_resp_ctrls, retoid, retval = self.result4(
  File "/usr/lib/python3/dist-packages/ldap/ldapobject.py", line 758, in result4
    ldap_result = self._ldap_call(self._l.result4,msgid,all,timeout,add_ctrls,add_intermediates,add_extop)
  File "/usr/lib/python3/dist-packages/ldap/ldapobject.py", line 331, in _ldap_call
    reraise(exc_type, exc_value, exc_traceback)
  File "/usr/lib/python3/dist-packages/ldap/compat.py", line 44, in reraise
    raise exc_value
  File "/usr/lib/python3/dist-packages/ldap/ldapobject.py", line 315, in _ldap_call
    result = func(*args,**kwargs)
ldap.NOT_ALLOWED_ON_NONLEAF: {'desc': 'Operation not allowed on non-leaf', 'info': 'subordinate objects must be deleted first'}
fatal: [brumaire.yaal.coop]: FAILED! => {
    "changed": false,
    "details": "{'desc': 'Operation not allowed on non-leaf', 'info': 'subordinate objects must be deleted first'}",
    "invocation": {
        "module_args": {
            "attributes": {
                "objectClass": null
            },
            "bind_dn": "cn=admin,dc=mydomain,dc=tld",
            "bind_pw": "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER",
            "dn": "ou=Users,dc=mydomain,dc=tld",
            "objectClass": null,
            "params": null,
            "server_uri": "ldapi:///",
            "start_tls": false,
            "state": "absent",
            "validate_certs": true
        }
    },
    "msg": "Entry action failed."
```

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/net_tools/ldap/ldap_entry.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/net_tools/ldap/ldap_entry.py)

If these files are incorrect, 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/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @jtyr
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/plugins/modules/net_tools/ldap/ldap_entry.py b/plugins/modules/net_tools/ldap/ldap_entry.py
--- a/plugins/modules/net_tools/ldap/ldap_entry.py
+++ b/plugins/modules/net_tools/ldap/ldap_entry.py
@@ -49,6 +49,13 @@
     choices: [present, absent]
     default: present
     type: str
+  recursive:
+    description:
+      - If I(state=delete), a flag indicating whether a single entry or the
+        whole branch must be deleted.
+    type: bool
+    default: false
+    version_added: 4.6.0
 extends_documentation_fragment:
 - community.general.ldap.documentation
 
@@ -110,6 +117,7 @@
 LDAP_IMP_ERR = None
 try:
     import ldap.modlist
+    import ldap.controls
 
     HAS_LDAP = True
 except ImportError:
@@ -123,6 +131,7 @@ def __init__(self, module):
 
         # Shortcuts
         self.state = self.module.params['state']
+        self.recursive = self.module.params['recursive']
 
         # Add the objectClass into the list of attributes
         self.module.params['attributes']['objectClass'] = (
@@ -158,12 +167,29 @@ def _add():
         return action
 
     def delete(self):
-        """ If self.dn exists, returns a callable that will delete it. """
+        """ If self.dn exists, returns a callable that will delete either
+        the item itself if the recursive option is not set or the whole branch
+        if it is. """
         def _delete():
             self.connection.delete_s(self.dn)
 
+        def _delete_recursive():
+            """ Attempt recurive deletion using the subtree-delete control.
+            If that fails, do it manually. """
+            try:
+                subtree_delete = ldap.controls.ValueLessRequestControl('1.2.840.113556.1.4.805')
+                self.connection.delete_ext_s(self.dn, serverctrls=[subtree_delete])
+            except ldap.NOT_ALLOWED_ON_NONLEAF:
+                search = self.connection.search_s(self.dn, ldap.SCOPE_SUBTREE, attrlist=('dn',))
+                search.reverse()
+                for entry in search:
+                    self.connection.delete_s(entry[0])
+
         if self._is_entry_present():
-            action = _delete
+            if self.recursive:
+                action = _delete_recursive
+            else:
+                action = _delete
         else:
             action = None
 
@@ -186,6 +212,7 @@ def main():
             attributes=dict(default={}, type='dict'),
             objectClass=dict(type='list', elements='str'),
             state=dict(default='present', choices=['present', 'absent']),
+            recursive=dict(default=False, type='bool'),
         ),
         required_if=[('state', 'present', ['objectClass'])],
         supports_check_mode=True,


Test patch: