diff --git a/changelogs/fragments/86140-inventory-limit-implicit-localhost.yml b/changelogs/fragments/86140-inventory-limit-implicit-localhost.yml new file mode 100644 index 00000000000..143913afd10 --- /dev/null +++ b/changelogs/fragments/86140-inventory-limit-implicit-localhost.yml @@ -0,0 +1,2 @@ +bugfixes: + - "inventory - Refactored pattern evaluation to use a filter-based approach, eliminating duplicate work and properly preserving implicit localhost (https://github.com/ansible/ansible/issues/86140)." diff --git a/lib/ansible/inventory/data.py b/lib/ansible/inventory/data.py index f879baa4016..1688a0306d4 100644 --- a/lib/ansible/inventory/data.py +++ b/lib/ansible/inventory/data.py @@ -63,8 +63,10 @@ class InventoryData: def _create_implicit_localhost(self, pattern: str) -> Host: - if self.localhost: + if self.localhost and self.localhost.implicit: new_host = self.localhost + elif self.localhost: + return None else: new_host = Host(pattern) diff --git a/lib/ansible/inventory/manager.py b/lib/ansible/inventory/manager.py index 615b5a6d2eb..c44d8ad18a2 100644 --- a/lib/ansible/inventory/manager.py +++ b/lib/ansible/inventory/manager.py @@ -44,6 +44,7 @@ from ansible.utils.vars import combine_vars from ansible.vars.plugins import get_vars_from_inventory_sources if t.TYPE_CHECKING: + from ansible.inventory.host import Host from ansible.plugins.inventory import BaseInventoryPlugin display = Display() @@ -67,10 +68,13 @@ PATTERN_WITH_SUBSCRIPT = re.compile( ) -def order_patterns(patterns): - """ takes a list of patterns and reorders them by modifier to apply them consistently """ +def order_patterns(patterns: list[str]) -> tuple[list[str], list[str], list[str]]: + """ + Takes a list of patterns and groups them by modifier. + Returns tuple of (regular_patterns, intersection_patterns, exclude_patterns). + Regular patterns are unioned, then intersections applied, then exclusions. + """ - # FIXME: this goes away if we apply patterns incrementally or by groups pattern_regular = [] pattern_intersection = [] pattern_exclude = [] @@ -85,14 +89,7 @@ def order_patterns(patterns): else: pattern_regular.append(p) - # if no regular pattern was given, hence only exclude and/or intersection - # make that magically work - if pattern_regular == []: - pattern_regular = ['all'] - - # when applying the host selectors, run those without the "&" or "!" - # first, then the &s, then the !s. - return pattern_regular + pattern_intersection + pattern_exclude + return pattern_regular, pattern_intersection, pattern_exclude def split_host_pattern(pattern): @@ -407,17 +404,15 @@ class InventoryManager(object): if pattern_hash not in self._hosts_patterns_cache: - patterns = split_host_pattern(pattern) - hosts = self._evaluate_patterns(patterns) + hosts = list(self._inventory.hosts.values()) - # mainly useful for hostvars[host] access if not ignore_limits and self._subset: - # exclude hosts not in a subset, if defined - subset_uuids = set(s._uuid for s in self._evaluate_patterns(self._subset)) - hosts = [h for h in hosts if h._uuid in subset_uuids] + hosts = self._evaluate_patterns(self._subset, hosts) + + patterns = split_host_pattern(pattern) + hosts = self._evaluate_patterns(patterns, hosts) if not ignore_restrictions and self._restriction: - # exclude hosts mentioned in any restriction (ex: failed hosts) hosts = [h for h in hosts if h.name in self._restriction] self._hosts_patterns_cache[pattern_hash] = deduplicate_list(hosts) @@ -436,30 +431,41 @@ class InventoryManager(object): return hosts - def _evaluate_patterns(self, patterns): + def _evaluate_patterns(self, patterns: list[str], hosts: list[Host]) -> list[Host]: """ - Takes a list of patterns and returns a list of matching host names, - taking into account any negative and intersection patterns. + Filters a list of hosts by applying patterns. """ - patterns = order_patterns(patterns) - hosts = [] + pattern_regular, pattern_intersection, pattern_exclude = order_patterns(patterns) + hosts_set = set(hosts) - for p in patterns: - # avoid resolving a pattern that is a plain host - if p in self._inventory.hosts: - hosts.append(self._inventory.get_host(p)) - else: - that = self._match_one_pattern(p) - if p[0] == "!": - that = set(that) - hosts = [h for h in hosts if h not in that] - elif p[0] == "&": - that = set(that) - hosts = [h for h in hosts if h in that] + if pattern_regular: + matched = set() + for p in pattern_regular: + if p in self._inventory.hosts: + target_host = self._inventory.get_host(p) + if target_host in hosts_set: + matched.add(target_host) else: - existing_hosts = set(y.name for y in hosts) - hosts.extend([h for h in that if h.name not in existing_hosts]) + that = self._match_one_pattern(p) + matched.update(h for h in that if h in hosts_set or h.implicit) + + hosts = [h for h in hosts if h in matched] + for h in matched - hosts_set: + if h.implicit: + hosts.append(h) + hosts_set = matched + + for p in pattern_intersection: + that = set(self._match_one_pattern(p)) + hosts = [h for h in hosts if h in that] + hosts_set = hosts_set & that + + for p in pattern_exclude: + that = set(self._match_one_pattern(p)) + hosts = [h for h in hosts if h not in that] + hosts_set = hosts_set - that + return hosts def _match_one_pattern(self, pattern): diff --git a/test/integration/targets/limit_inventory/exclude_localhost.yml b/test/integration/targets/limit_inventory/exclude_localhost.yml new file mode 100644 index 00000000000..0c47a653039 --- /dev/null +++ b/test/integration/targets/limit_inventory/exclude_localhost.yml @@ -0,0 +1,10 @@ +- hosts: all,localhost + gather_facts: false + tasks: + - debug: + msg: "Host: {{ inventory_hostname }}" + + - assert: + that: + - inventory_hostname != 'localhost' + fail_msg: "localhost should have been excluded by limit" diff --git a/test/integration/targets/limit_inventory/hosts_with_localhost.yml b/test/integration/targets/limit_inventory/hosts_with_localhost.yml new file mode 100644 index 00000000000..07cad966604 --- /dev/null +++ b/test/integration/targets/limit_inventory/hosts_with_localhost.yml @@ -0,0 +1,6 @@ +all: + hosts: + host1: + host2: + host3: + localhost: diff --git a/test/integration/targets/limit_inventory/include_localhost.yml b/test/integration/targets/limit_inventory/include_localhost.yml new file mode 100644 index 00000000000..875ebaf3ef3 --- /dev/null +++ b/test/integration/targets/limit_inventory/include_localhost.yml @@ -0,0 +1,10 @@ +- hosts: all,localhost + gather_facts: false + tasks: + - set_fact: + include_localhost: true + + - assert: + that: + - hostvars.localhost.include_localhost is true + run_once: true diff --git a/test/integration/targets/limit_inventory/runme.sh b/test/integration/targets/limit_inventory/runme.sh index 6a142b3b1e2..1fb8143251a 100755 --- a/test/integration/targets/limit_inventory/runme.sh +++ b/test/integration/targets/limit_inventory/runme.sh @@ -29,3 +29,21 @@ ansible -i hosts.yml host1,,host3 --list-hosts | tee out ; grep -q 'hosts (2)' o ansible -i hosts.yml all --limit 'host1, , ,host3' --list-hosts | tee out ; grep -q 'hosts (2)' out ansible -i hosts.yml 'host1, , ,host3' --list-hosts | tee out ; grep -q 'hosts (2)' out +# Intersection patterns with limits +ansible -i hosts.yml all --limit '&host1' --list-hosts | tee out ; grep -q 'hosts (1)' out + +# Multiple negations +ansible -i hosts.yml all --limit '!host1:!host2' --list-hosts | tee out ; grep -q 'hosts (1)' out +ansible -i hosts.yml all --limit '!host1,!host2' --list-hosts | tee out ; grep -q 'hosts (1)' out + +# Negation-only limit with 'all' pattern (shouldn't add implicit localhost) +ansible -i hosts.yml all --limit '!host2' --list-hosts | tee out ; grep -q 'hosts (2)' out + +# ensure implicit localhost available with limits when explicitly in play pattern +ansible-playbook -i hosts.yml --limit '!host2' include_localhost.yml + +# Explicitly excluding localhost should work when inventory has explicit localhost +ansible-playbook -i hosts_with_localhost.yml --limit '!localhost' exclude_localhost.yml + +# localhost-only play with non-localhost limit should still include localhost +ansible -i hosts.yml localhost --limit 'host1' --list-hosts | tee out ; grep -q 'hosts (1)' out