diff --git a/changelogs/fragments/2282-nmap-fix-cache-support.yml b/changelogs/fragments/2282-nmap-fix-cache-support.yml new file mode 100644 index 0000000000..62b026eb25 --- /dev/null +++ b/changelogs/fragments/2282-nmap-fix-cache-support.yml @@ -0,0 +1,2 @@ +bugfixes: + - nmap inventory plugin - fix cache and constructed group support (https://github.com/ansible-collections/community.general/issues/2242). diff --git a/plugins/inventory/nmap.py b/plugins/inventory/nmap.py index ea6175e86d..687317abfa 100644 --- a/plugins/inventory/nmap.py +++ b/plugins/inventory/nmap.py @@ -71,6 +71,25 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): 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 @@ -82,7 +101,7 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): 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') @@ -93,75 +112,101 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): 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: - t_stdout = to_text(stdout, errors='surrogate_or_strict') - except UnicodeError as e: - raise AnsibleParserError('Invalid (non unicode) input returned: %s' % to_native(e)) + 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 - 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 cache_needs_update: + # setup command + cmd = [self._nmap] + if not self._options['ports']: + cmd.append('-sP') - # 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 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 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 self._options['exclude']: + cmd.append('--exclude') + cmd.append(','.join(self._options['exclude'])) - if host is not None: - # update inventory - self.inventory.add_host(host) - self.inventory.set_variable(host, 'ip', ip) - ports = [] - continue + 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))) - 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 + # parse results + host = None + ip = None + ports = [] + results = [] - # TODO: parse more data, OS? + 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)) - # if any leftovers - if host and ports: - self.inventory.set_variable(host, 'ports', ports) + 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 - except Exception as e: - raise AnsibleParserError("failed to parse %s: %s " % (to_native(path), to_native(e))) + # 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)