Repository: ansible-collections/community.general

Issue:
nmap does not support cache which docs indicate
### Summary

I submitted a PR to fix this  while the split was happening, should I redo it to this repo as well?
https://github.com/ansible/ansible/pull/56931

### Issue Type

Bug Report

### Component Name

nmap

### Ansible Version

```console (paste below)
ansible 2.10.7
```

### Configuration

_No response_

### OS / Environment

_No response_

### Steps to Reproduce

<!--- Paste example playbooks or commands between quotes below -->
Run any playbook using nmap inventory with cache enabled twice

### Expected Results

Second run should use cache and be almost instant

### Actual Results

Same execution time every run

### Code of Conduct

I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/inventory/nmap.py`](https://github.com/ansible-collections/community.general/blob/master/plugins/inventory/nmap.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 --->
If you're still interested in making your change in ansible package, yes, please open a PR in this repository.

Resolving patch:
diff --git a/plugins/inventory/nmap.py b/plugins/inventory/nmap.py
--- a/plugins/inventory/nmap.py
+++ b/plugins/inventory/nmap.py
@@ -72,6 +72,25 @@ def __init__(self):
         self._nmap = None
         super(InventoryModule, self).__init__()
 
+    def _populate(self, hosts):
+        # Use constructed if applicable
+        strict = self.get_option('strict')
+
+        for host in hosts:
+            hostname = host['name']
+            self.inventory.add_host(hostname)
+            for var, value in host.items():
+                self.inventory.set_variable(hostname, var, value)
+
+            # Composed variables
+            self._set_composite_vars(self.get_option('compose'), host, hostname, strict=strict)
+
+            # Complex groups based on jinja2 conditionals, hosts that meet the conditional are added to group
+            self._add_host_to_composed_groups(self.get_option('groups'), host, hostname, strict=strict)
+
+            # Create groups based on variable values and add the corresponding hosts to it
+            self._add_host_to_keyed_groups(self.get_option('keyed_groups'), host, hostname, strict=strict)
+
     def verify_file(self, path):
 
         valid = False
@@ -83,7 +102,7 @@ def verify_file(self, path):
 
         return valid
 
-    def parse(self, inventory, loader, path, cache=False):
+    def parse(self, inventory, loader, path, cache=True):
 
         try:
             self._nmap = get_bin_path('nmap')
@@ -94,75 +113,101 @@ def parse(self, inventory, loader, path, cache=False):
 
         self._read_config_data(path)
 
-        # setup command
-        cmd = [self._nmap]
-        if not self._options['ports']:
-            cmd.append('-sP')
+        cache_key = self.get_cache_key(path)
 
-        if self._options['ipv4'] and not self._options['ipv6']:
-            cmd.append('-4')
-        elif self._options['ipv6'] and not self._options['ipv4']:
-            cmd.append('-6')
-        elif not self._options['ipv6'] and not self._options['ipv4']:
-            raise AnsibleParserError('One of ipv4 or ipv6 must be enabled for this plugin')
+        # cache may be True or False at this point to indicate if the inventory is being refreshed
+        # get the user's cache option too to see if we should save the cache if it is changing
+        user_cache_setting = self.get_option('cache')
 
-        if self._options['exclude']:
-            cmd.append('--exclude')
-            cmd.append(','.join(self._options['exclude']))
-
-        cmd.append(self._options['address'])
-        try:
-            # execute
-            p = Popen(cmd, stdout=PIPE, stderr=PIPE)
-            stdout, stderr = p.communicate()
-            if p.returncode != 0:
-                raise AnsibleParserError('Failed to run nmap, rc=%s: %s' % (p.returncode, to_native(stderr)))
-
-            # parse results
-            host = None
-            ip = None
-            ports = []
+        # read if the user has caching enabled and the cache isn't being refreshed
+        attempt_to_read_cache = user_cache_setting and cache
+        # update if the user has caching enabled and the cache is being refreshed; update this value to True if the cache has expired below
+        cache_needs_update = user_cache_setting and not cache
 
+        if attempt_to_read_cache:
+            try:
+                results = self._cache[cache_key]
+            except KeyError:
+                # This occurs if the cache_key is not in the cache or if the cache_key expired, so the cache needs to be updated
+                cache_needs_update = True
+
+        if cache_needs_update:
+            # setup command
+            cmd = [self._nmap]
+            if not self._options['ports']:
+                cmd.append('-sP')
+
+            if self._options['ipv4'] and not self._options['ipv6']:
+                cmd.append('-4')
+            elif self._options['ipv6'] and not self._options['ipv4']:
+                cmd.append('-6')
+            elif not self._options['ipv6'] and not self._options['ipv4']:
+                raise AnsibleParserError('One of ipv4 or ipv6 must be enabled for this plugin')
+
+            if self._options['exclude']:
+                cmd.append('--exclude')
+                cmd.append(','.join(self._options['exclude']))
+
+            cmd.append(self._options['address'])
             try:
-                t_stdout = to_text(stdout, errors='surrogate_or_strict')
-            except UnicodeError as e:
-                raise AnsibleParserError('Invalid (non unicode) input returned: %s' % to_native(e))
-
-            for line in t_stdout.splitlines():
-                hits = self.find_host.match(line)
-                if hits:
-                    if host is not None:
-                        self.inventory.set_variable(host, 'ports', ports)
-
-                    # if dns only shows arpa, just use ip instead as hostname
-                    if hits.group(1).endswith('.in-addr.arpa'):
-                        host = hits.group(2)
-                    else:
-                        host = hits.group(1)
-
-                    # if no reverse dns exists, just use ip instead as hostname
-                    if hits.group(2) is not None:
-                        ip = hits.group(2)
-                    else:
-                        ip = hits.group(1)
-
-                    if host is not None:
-                        # update inventory
-                        self.inventory.add_host(host)
-                        self.inventory.set_variable(host, 'ip', ip)
-                        ports = []
-                    continue
-
-                host_ports = self.find_port.match(line)
-                if host is not None and host_ports:
-                    ports.append({'port': host_ports.group(1), 'protocol': host_ports.group(2), 'state': host_ports.group(3), 'service': host_ports.group(4)})
-                    continue
-
-                # TODO: parse more data, OS?
-
-            # if any leftovers
-            if host and ports:
-                self.inventory.set_variable(host, 'ports', ports)
-
-        except Exception as e:
-            raise AnsibleParserError("failed to parse %s: %s " % (to_native(path), to_native(e)))
+                # execute
+                p = Popen(cmd, stdout=PIPE, stderr=PIPE)
+                stdout, stderr = p.communicate()
+                if p.returncode != 0:
+                    raise AnsibleParserError('Failed to run nmap, rc=%s: %s' % (p.returncode, to_native(stderr)))
+
+                # parse results
+                host = None
+                ip = None
+                ports = []
+                results = []
+
+                try:
+                    t_stdout = to_text(stdout, errors='surrogate_or_strict')
+                except UnicodeError as e:
+                    raise AnsibleParserError('Invalid (non unicode) input returned: %s' % to_native(e))
+
+                for line in t_stdout.splitlines():
+                    hits = self.find_host.match(line)
+                    if hits:
+                        if host is not None and ports:
+                            results[-1]['ports'] = ports
+
+                        # if dns only shows arpa, just use ip instead as hostname
+                        if hits.group(1).endswith('.in-addr.arpa'):
+                            host = hits.group(2)
+                        else:
+                            host = hits.group(1)
+
+                        # if no reverse dns exists, just use ip instead as hostname
+                        if hits.group(2) is not None:
+                            ip = hits.group(2)
+                        else:
+                            ip = hits.group(1)
+
+                        if host is not None:
+                            # update inventory
+                            results.append(dict())
+                            results[-1]['name'] = host
+                            results[-1]['ip'] = ip
+                            ports = []
+                        continue
+
+                    host_ports = self.find_port.match(line)
+                    if host is not None and host_ports:
+                        ports.append({'port': host_ports.group(1),
+                                      'protocol': host_ports.group(2),
+                                      'state': host_ports.group(3),
+                                      'service': host_ports.group(4)})
+                        continue
+
+                # if any leftovers
+                if host and ports:
+                    results[-1]['ports'] = ports
+
+            except Exception as e:
+                raise AnsibleParserError("failed to parse %s: %s " % (to_native(path), to_native(e)))
+
+            self._cache[cache_key] = results
+
+        self._populate(results)


Test patch: