Skip to content

Commit a0db353

Browse files
committed
feat: add get_hosts_by_ansible function
1 parent dca7b05 commit a0db353

File tree

5 files changed

+200
-7
lines changed

5 files changed

+200
-7
lines changed

doc/source/backends.rst

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,51 @@ https://docs.ansible.com/ansible/latest/reference_appendices/config.html
194194
* ``ANSIBLE_BECOME_USER``
195195
* ``ANSIBLE_BECOME``
196196

197+
Advanced hosts expressions for ansible
198+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
199+
200+
It is possible to use most of the
201+
`Ansible host expressions <https://docs.ansible.com/ansible/latest/inventory_guide/intro_patterns.html>`
202+
in Testinfra.
203+
204+
Supported:
205+
206+
* ``&``, ``!``, ``,``, ``:``
207+
* glob expressions (``server*`` with match server1, server2, etc)
208+
* ranges (``[x:y]``) are supported with replacement with round brackets.
209+
``mygroup[1:2]`` should be written as ``mygroup(1:2)``, this is due to limitation
210+
of what is allowed in the host part of the URL.
211+
When host expression is passed to ansible for parsing, ``()`` are replaced with
212+
``[]``.
213+
214+
Regular expressions (starting with '~') are not supported due to limitations
215+
for allowed characters in the host part of the URL.
216+
217+
When testinfra parses host expressions, it choose:
218+
219+
* A simple resolver, if there is no host expression (e.g. a single group,
220+
hostname, or glob pattern)
221+
* Ansible resolver, which covers most cases. It requires to have ansible-core
222+
been present on the controller (host, where pytest is running). It imports
223+
part of ansible to do expression evaluation, os it's slower.
224+
225+
Examples of the simple host expression (Ansible is not used for parsing):
226+
227+
* ``ansible://debian_bookworm``
228+
* ``ansible://user@debian_bookworm?force_ansible=True&sudo=True``
229+
* ``ansible://host*``
230+
231+
Examples of the Ansible-parsed host expressions:
232+
233+
* ``ansible://group1,!group3`` (hosts in group1 but not in group3)
234+
* ``ansible://group1(0)`` (the first host in the group). This can be used as a substitute
235+
for run_once.
236+
* ``ansible://group1,&group3`` (hosts in both group1 and group2)
237+
* ``ansible://group1,group2,!group3,example*`` (hosts in group1 or group2 but not
238+
in group3, and hosts matching regular expression ``(example1.*)``)
239+
* ``ansible://group1,group2,!group3,example*?force_ansible=True&sudo=True``
240+
(the same, but forcing Ansible backend and adds sudo)
241+
197242
kubectl
198243
~~~~~~~
199244

mypy.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@ ignore_missing_imports = True
1919

2020
[mypy-setuptools_scm.*]
2121
ignore_missing_imports = True
22+
23+
[mypy-ansible.*]
24+
ignore_missing_imports = True

test/test_ansible_host_expressions.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
import os
14+
import tempfile
15+
16+
import pytest
17+
18+
from testinfra.utils.ansible_runner import AnsibleRunner
19+
20+
INVENTORY = b"""
21+
---
22+
group1:
23+
hosts:
24+
host1:
25+
host2:
26+
group2:
27+
hosts:
28+
host3:
29+
host4:
30+
group3:
31+
hosts:
32+
host1:
33+
host4:
34+
group4:
35+
hosts:
36+
example1:
37+
example11:
38+
example2:
39+
"""
40+
41+
42+
@pytest.fixture(scope="module")
43+
def get_hosts():
44+
with tempfile.NamedTemporaryFile() as f:
45+
f.write(INVENTORY)
46+
f.flush()
47+
48+
def _get_hosts(spec):
49+
return AnsibleRunner(f.name).get_hosts(spec)
50+
51+
yield _get_hosts
52+
53+
54+
@pytest.fixture(scope="function")
55+
def get_env_var():
56+
old_value = os.environ.get("ANSIBLE_INVENTORY")
57+
with tempfile.NamedTemporaryFile() as f:
58+
f.write(INVENTORY)
59+
f.flush()
60+
os.environ["ANSIBLE_INVENTORY"] = f.name
61+
62+
def _get_env_hosts(spec):
63+
return AnsibleRunner(None).get_hosts(spec)
64+
65+
yield _get_env_hosts
66+
if old_value:
67+
os.environ["ANSIBLE_INVENTORY"] = old_value
68+
else:
69+
del os.environ["ANSIBLE_INVENTORY"]
70+
71+
72+
def test_ansible_host_expressions_index(get_hosts):
73+
assert get_hosts("group1(0)") == ["host1"]
74+
75+
76+
def test_ansible_host_expressions_negative_index(get_hosts):
77+
assert get_hosts("group1(-1)") == ["host2"]
78+
79+
80+
def test_ansible_host_expressions_not(get_hosts):
81+
assert get_hosts("group1,!group3") == ["host2"]
82+
83+
84+
def test_ansible_host_expressions_and(get_hosts):
85+
assert get_hosts("group1,&group3") == ["host1"]
86+
87+
88+
def test_ansible_host_complicated_expression(get_hosts):
89+
expression = "group1,group2,!group3,example1*"
90+
assert set(get_hosts(expression)) == {"host2", "host3", "example1", "example11"}
91+
92+
93+
def test_ansible_host_regexp(get_hosts):
94+
with pytest.raises(ValueError):
95+
get_hosts("~example1*")
96+
97+
98+
def test_ansible_host_with_ansible_inventory_env_var(get_env_var):
99+
assert set(get_env_var("host1,example1*")) == {"host1", "example1", "example11"}

testinfra/utils/ansible_runner.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,42 @@ def __init__(self, inventory_file: Optional[str] = None):
277277
self._host_cache: dict[str, Optional[testinfra.host.Host]] = {}
278278
super().__init__()
279279

280+
def get_hosts_by_ansible(self, host_expression: str) -> list[str]:
281+
"""Evaluate ansible host expression to get host list.
282+
283+
Example of such expression:
284+
285+
'foo,&bar,!baz[2:],foobar[-3:-4],foofoo-*,~someth.+'
286+
287+
See https://docs.ansible.com/ansible/latest/inventory_guide/intro_patterns.html#common-patterns
288+
"""
289+
from ansible.inventory.manager import InventoryManager
290+
from ansible.parsing.dataloader import DataLoader
291+
292+
# We can't support 'group[1:2]', expressions 'as is',
293+
# because urllib from python 3.13+ rejects hostnames with invalid
294+
# IPv6 addresses (in square brakets)
295+
# E ValueError: Invalid IPv6 URL
296+
# We ask user to use round brakets in testinfra 'URL', and
297+
# replace it back to ansible-compatible expression here.
298+
host_expression = host_expression.replace("(", "[")
299+
host_expression = host_expression.replace(")", "]")
300+
if self.inventory_file:
301+
sources = [self.inventory_file]
302+
else:
303+
# we search for other options only if inventory is not passed
304+
# explicitely.
305+
# Inside ansible, 'ANSIBLE_INVENTORY' is 'DEFAULT_HOST_LIST'
306+
# We respect both ANSIBLE_INVENTORY env var and ansible.cfg
307+
from ansible.config.manager import ConfigManager
308+
309+
sources = ConfigManager().get_config_value("DEFAULT_HOST_LIST")
310+
311+
loader = DataLoader()
312+
inv = InventoryManager(loader=loader, sources=sources)
313+
hosts = [h.name for h in inv.list_hosts(host_expression)]
314+
return list(hosts)
315+
280316
def get_hosts(self, pattern: str = "all") -> list[str]:
281317
inventory = self.inventory
282318
result = set()
@@ -290,13 +326,22 @@ def get_hosts(self, pattern: str = "all") -> list[str]:
290326
"only implicit localhost is available"
291327
)
292328
else:
293-
for group in inventory:
294-
groupmatch = fnmatch.fnmatch(group, pattern)
295-
if groupmatch:
296-
result |= set(itergroup(inventory, group))
297-
for host in inventory[group].get("hosts", []):
298-
if fnmatch.fnmatch(host, pattern):
299-
result.add(host)
329+
if "~" in pattern:
330+
raise ValueError(
331+
"Regular expressions are not supported in host expression. "
332+
"Found '~' in the host expression."
333+
)
334+
special_char_list = "!&,:()" # signs of host expression
335+
if any(ch in special_char_list for ch in pattern):
336+
result = result.union(self.get_hosts_by_ansible(pattern))
337+
else:
338+
for group in inventory:
339+
groupmatch = fnmatch.fnmatch(group, pattern)
340+
if groupmatch:
341+
result |= set(itergroup(inventory, group))
342+
for host in inventory[group].get("hosts", []):
343+
if fnmatch.fnmatch(host, pattern):
344+
result.add(host)
300345
return sorted(result)
301346

302347
@functools.cached_property

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ commands=
3636
description = Performs typing check
3737
extras =
3838
typing
39+
ansible
3940
usedevelop=True
4041
commands=
4142
mypy

0 commit comments

Comments
 (0)