From a33fcf45f83dabf5b6da70b200571695e3917a75 Mon Sep 17 00:00:00 2001
From: Rafael Guterres Jeffman <rjeffman@redhat.com>
Date: Mon, 10 Apr 2023 20:36:26 -0300
Subject: [PATCH] ipaautomountmap: add support for indirect maps

Indirect maps were not supported by ansible-freeipa ipaautomountmap.
This patch adds support for adding indirect automount maps using the
"parent" and "mount" parameters, if the map do not yet exist. An
existing map cannot be modified.

The "parent" parameter must match an existing automount map, and the
"mount" parameter is required if "parent" is used.

A new example playbook can be found at:

    playbooks/automount/automount-map-indirect-map.yml

A new test playbook was added to test the feature:

    tests/automount/test_automountmap_indirect.yml
---
 README-automountmap.md                        |  25 ++-
 .../automount/automount-map-indirect-map.yml  |  14 ++
 plugins/modules/ipaautomountmap.py            | 155 +++++++++++++++++-
 .../automount/test_automountmap_indirect.yml  | 143 ++++++++++++++++
 4 files changed, 325 insertions(+), 12 deletions(-)
 create mode 100644 playbooks/automount/automount-map-indirect-map.yml
 create mode 100644 tests/automount/test_automountmap_indirect.yml

diff --git a/README-automountmap.md b/README-automountmap.md
index 208dd53d..77e6af09 100644
--- a/README-automountmap.md
+++ b/README-automountmap.md
@@ -54,6 +54,21 @@ Example playbook to ensure presence of an automount map:
       desc: "this is a map for servers in the DMZ"
 ```
 
+Automount maps can contain a submount key, which defines a mount location within the map the references another map. On FreeIPA, this is known as an indirect map. An indirect automount map is equivalent to adding a proper automount key to a map, referencyng another map (this second map is the indirect map). Use `parent` and `mount` parameters to create an indirect automount map with ansible-freeipa, without the need to directly manage the automount keys.
+
+Example playbook to ensure an indirect automount map is present:
+
+```yaml
+---
+- name: Playbook to add an indirect automount map
+  ipaautomountmap:
+    ipaadmin_password: SomeADMINpassword
+    name: auto.indirect
+    location: DMZ
+    parent: auto.DMZ
+    mount: dmz_indirect
+```
+
 Example playbook to ensure auto.DMZi is absent:
 
 ```yaml
@@ -81,16 +96,14 @@ Variable | Description | Required
 `ipaadmin_password` | The admin password is a string and is required if there is no admin ticket available on the node | no
 `name` \| `mapname` \| `map` \| `automountmapname` | Name of the map to manage | yes
 `location` \| `automountlocation` \| `automountlocationcn` | Location name. | yes
+`parentmap` | Parent map of the indirect map. Can only be used when creating new maps. Default: auto.master | no
+`mount` | Indirect map mount point, relative to parent map. | yes, if `parent` is used.
 `desc` \| `description` | Description of the map | yes
 `state` | The state to ensure. It can be one of `present`, or `absent`, default: `present`. | no
 
 
-Notes
-=====
-
-Creation of indirect mount points are not supported.
-
 Authors
 =======
 
-Chris Procter
+- Chris Procter
+- Rafael Jeffman
diff --git a/playbooks/automount/automount-map-indirect-map.yml b/playbooks/automount/automount-map-indirect-map.yml
new file mode 100644
index 00000000..38359e55
--- /dev/null
+++ b/playbooks/automount/automount-map-indirect-map.yml
@@ -0,0 +1,14 @@
+---
+- name: Managed automount maps
+  hosts: ipaserver
+  become: false
+  gather_facts: false
+
+  tasks:
+  - name: Playbook to add an indirect automount map
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      name: auto.indirect
+      location: DMZ
+      parent: auto.DMZ
+      mount: dmz_indirect
diff --git a/plugins/modules/ipaautomountmap.py b/plugins/modules/ipaautomountmap.py
index 1590ebb6..668e887a 100644
--- a/plugins/modules/ipaautomountmap.py
+++ b/plugins/modules/ipaautomountmap.py
@@ -37,6 +37,7 @@ module: ipaautomountmap
 author:
   - Chris Procter (@chr15p)
   - Thomas Woerner (@t-woerner)
+  - Rafael Jeffman (@rjeffman)
 short_description: Manage FreeIPA autommount map
 description:
 - Add, delete, and modify an IPA automount map
@@ -59,6 +60,16 @@ options:
     type: str
     aliases: ["description"]
     required: false
+  parentmap:
+    description: |
+      Parent map of the indirect map. Can only be used when creating
+      new maps.
+    type: str
+    required: false
+  mount:
+    description: Indirect map mount point, relative to parent map.
+    type: str
+    required: false
   state:
     description: State to ensure
     type: str
@@ -75,6 +86,14 @@ EXAMPLES = '''
       location: DMZ
       desc: "this is a map for servers in the DMZ"
 
+  - name: ensure indirect map exists
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      name: auto.INDIRECT
+      location: DMZ
+      parentmap: auto.DMZ
+      mount: indirect
+
   - name: remove a map named auto.DMZ in location DMZ if it exists
     ipaautomountmap:
       ipaadmin_password: SomeADMINpassword
@@ -110,6 +129,35 @@ class AutomountMap(IPAAnsibleModule):
         else:
             return response["result"]
 
+    def get_indirect_map_keys(self, location, name):
+        """Check if 'name' is an indirect map for 'parentmap'."""
+        try:
+            maps = self.ipa_command("automountmap_find", location, {})
+        except Exception:  # pylint: disable=broad-except
+            return []
+
+        result = []
+        for check_map in maps.get("result", []):
+            _mapname = check_map['automountmapname'][0]
+            keys = self.ipa_command(
+                "automountkey_find",
+                location,
+                {
+                    "automountmapautomountmapname": _mapname,
+                    "all": True
+                }
+            )
+            cmp_value = (
+                name if _mapname == "auto.master" else "ldap:{0}".format(name)
+            )
+            result.extend([
+                (location, _mapname, key.get("automountkey")[0])
+                for key in keys.get("result", [])
+                for mount_info in key.get("automountinformation", [])
+                if cmp_value in mount_info
+            ])
+        return result
+
     def check_ipa_params(self):
         invalid = []
         name = self.params_get("name")
@@ -118,15 +166,27 @@ class AutomountMap(IPAAnsibleModule):
             if len(name) != 1:
                 self.fail_json(msg="Exactly one name must be provided for"
                                    " 'state: present'.")
+            mount = self.params_get("mount") or False
+            parentmap = self.params_get("parentmap")
+            if parentmap:
+                if not mount:
+                    self.fail_json(
+                        msg="Must provide 'mount' parameter for indirect map."
+                    )
+                elif parentmap != "auto.master" and mount[0] == "/":
+                    self.fail_json(
+                        msg="mount point is relative to parent map, "
+                            "cannot begin with '/'"
+                    )
         if state == "absent":
             if len(name) == 0:
                 self.fail_json(msg="At least one 'name' must be provided for"
                                    " 'state: absent'")
-            invalid = ["desc"]
+            invalid = ["desc", "parentmap", "mount"]
 
         self.params_fail_used_invalid(invalid, state)
 
-    def get_args(self, mapname, desc):
+    def get_args(self, mapname, desc, parentmap, mount):
         # automountmapname is required for all automountmap operations.
         if not mapname:
             self.fail_json(msg="automountmapname cannot be None or empty.")
@@ -134,6 +194,11 @@ class AutomountMap(IPAAnsibleModule):
         # An empty string is valid and will clear the attribute.
         if desc is not None:
             _args["description"] = desc
+        # indirect map attributes
+        if parentmap is not None:
+            _args["parentmap"] = parentmap
+        if mount is not None:
+            _args["key"] = mount
         return _args
 
     def define_ipa_commands(self):
@@ -141,28 +206,102 @@ class AutomountMap(IPAAnsibleModule):
         state = self.params_get("state")
         location = self.params_get("location")
         desc = self.params_get("desc")
+        mount = self.params_get("mount")
+        parentmap = self.params_get("parentmap")
 
         for mapname in name:
             automountmap = self.get_automountmap(location, mapname)
 
+            is_indirect_map = any([parentmap, mount])
+
             if state == "present":
-                args = self.get_args(mapname, desc)
+                args = self.get_args(mapname, desc, parentmap, mount)
                 if automountmap is None:
-                    self.commands.append([location, "automountmap_add", args])
+                    if is_indirect_map:
+                        if (
+                            parentmap and
+                            self.get_automountmap(location, parentmap) is None
+                        ):
+                            self.fail_json(msg="Parent map does not exist.")
+                        self.commands.append(
+                            [location, "automountmap_add_indirect", args]
+                        )
+                    else:
+                        self.commands.append(
+                            [location, "automountmap_add", args]
+                        )
                 else:
-                    if not compare_args_ipa(self, args, automountmap):
+                    has_changes = not compare_args_ipa(
+                        self, args, automountmap, ['parentmap', 'key']
+                    )
+                    if is_indirect_map:
+                        map_config = (
+                            location, parentmap or "auto.master", mount
+                        )
+                        indirects = self.get_indirect_map_keys(
+                            location, mapname
+                        )
+                        if map_config not in indirects or has_changes:
+                            self.fail_json(
+                                msg="Indirect maps can only be created, "
+                                    "not modified."
+                            )
+                    elif has_changes:
                         self.commands.append(
                             [location, "automountmap_mod", args]
                         )
 
-            if state == "absent":
+            elif state == "absent":
+                def find_keys(parent_loc, parent_map, parent_key):
+                    return self.ipa_command(
+                        "automountkey_show",
+                        parent_loc,
+                        {
+                            "automountmapautomountmapname": parent_map,
+                            "automountkey": parent_key,
+                        }
+                    ).get("result")
+
                 if automountmap is not None:
+                    indirects = self.get_indirect_map_keys(location, mapname)
+                    # Remove indirect map configurations for this map
+                    self.commands.extend([
+                        (
+                            ploc,
+                            "automountkey_del",
+                            {
+                                "automountmapautomountmapname": pmap,
+                                "automountkey": pkey,
+                            }
+                        )
+                        for ploc, pmap, pkey in indirects
+                        if find_keys(ploc, pmap, pkey)
+                    ])
+                    # Remove map
                     self.commands.append([
                         location,
                         "automountmap_del",
                         {"automountmapname": [mapname]}
                     ])
 
+            # ensure commands are unique and automountkey commands are
+            # executed first in the list
+            def hashable_dict(dictionaire):
+                return tuple(
+                    (k, tuple(v) if isinstance(v, (list, tuple)) else v)
+                    for k, v in dictionaire.items()
+                )
+
+            cmds = [
+                (name, cmd, hashable_dict(args))
+                for name, cmd, args in self.commands
+            ]
+            self.commands = [
+                (name, cmd, dict(args))
+                for name, cmd, args in
+                sorted(set(cmds), key=lambda cmd: cmd[1])
+            ]
+
 
 def main():
     ipa_module = AutomountMap(
@@ -184,6 +323,10 @@ def main():
                       required=False,
                       default=None
                       ),
+            parentmap=dict(
+                type="str", required=False, default=None
+            ),
+            mount=dict(type="str", required=False, default=None),
         ),
     )
     changed = False
diff --git a/tests/automount/test_automountmap_indirect.yml b/tests/automount/test_automountmap_indirect.yml
new file mode 100644
index 00000000..200fe1ef
--- /dev/null
+++ b/tests/automount/test_automountmap_indirect.yml
@@ -0,0 +1,143 @@
+---
+- name: Test automountmap
+  hosts: "{{ ipa_test_host | default('ipaserver') }}"
+  become: no
+  gather_facts: no
+
+  tasks:
+  # setup environment
+  - name: Ensure test maps are absent
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      location: TestIndirect
+      name:
+        - DirectMap
+        - IndirectMap
+        - IndirectMapDefault
+        - IndirectMapDefaultAbsolute
+      state: absent
+
+  - name: Ensure test location is present
+    ipaautomountlocation:
+      ipaadmin_password: SomeADMINpassword
+      name: TestIndirect
+
+  - name: Ensure parent map is present
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      location: TestIndirect
+      name: DirectMap
+
+  # TESTS
+  - name: Mount point cannot start with '/' if parentmap is not 'auto.master'
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      location: TestIndirect
+      name: IndirectMap
+      parentmap: DirectMap
+      mount: '/absolute/path/will/fail'
+    register: result
+    failed_when: not result.failed or 'mount point is relative to parent map' not in result.msg
+
+  - name: Ensure indirect map is present
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      location: TestIndirect
+      name: IndirectMap
+      parentmap: DirectMap
+      mount: indirect
+    register: result
+    failed_when: result.failed or not result.changed
+
+  - name: Ensure indirect map is present, again
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      location: TestIndirect
+      name: IndirectMap
+      parentmap: DirectMap
+      mount: indirect
+    register: result
+    failed_when: result.failed or result.changed
+
+  - name: Ensure indirect map is absent
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      location: TestIndirect
+      name: IndirectMap
+      state: absent
+    register: result
+    failed_when: result.failed or not result.changed
+
+  - name: Ensure indirect map is absent, again
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      location: TestIndirect
+      name: IndirectMap
+      state: absent
+    register: result
+    failed_when: result.failed or result.changed
+
+  - name: Ensure indirect map is present, after being deleted
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      location: TestIndirect
+      name: IndirectMap
+      parentmap: DirectMap
+      mount: indirect
+    register: result
+    failed_when: result.failed or not result.changed
+
+  - name: Ensure indirect map is present, after being deleted, again
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      location: TestIndirect
+      name: IndirectMap
+      parentmap: DirectMap
+      mount: indirect
+    register: result
+    failed_when: result.failed or result.changed
+
+  - name: Ensure indirect map is present with default parent (auto.master)
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      location: TestIndirect
+      name: IndirectMapDefault
+      mount: indirect_with_default
+    register: result
+    failed_when: result.failed or not result.changed
+
+  - name: Ensure indirect map is present with default parent (auto.master), again
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      location: TestIndirect
+      name: IndirectMapDefault
+      mount: indirect_with_default
+    register: result
+    failed_when: result.failed or result.changed
+
+  - name: Absolute paths must workd with 'auto.master'
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      location: TestIndirect
+      name: IndirectMapDefaultAbsolute
+      mount: /valid/path/indirect_with_default
+    register: result
+    failed_when: result.failed or not result.changed
+
+  # Cleanup
+  - name: Ensure test maps are absent
+    ipaautomountmap:
+      ipaadmin_password: SomeADMINpassword
+      location: TestIndirect
+      name:
+        - DirectMap
+        - IndirectMap
+        - IndirectMapDefault
+        - IndirectMapDefaultAbsolute
+      state: absent
+
+  - name: Ensure test location is absent
+    ipaautomountlocation:
+      ipaadmin_password: SomeADMINpassword
+      name: TestIndirect
+      state: absent
-- 
GitLab