Skip to content

Commit f1875b4

Browse files
committed
rabbitmq: resolve internal_interface through Ansible
get_rabbitmq_node_addresses() read internal_interface with "ansible-inventory --host", which returns variables as defined, so a Jinja-valued internal_interface came back as the raw "{{ ... }}". It then resolved that by hand: a regex captured the contents of the first "{{ ... }}" and the dotted path was walked through the ansible facts. That covers exactly one shape -- a single expression whose dotted path is rooted in facts, i.e. the testbed's internal_interface: "{{ ansible_local.testbed_network_devices.management }}" and nothing else: - An internal_interface pointing at an inventory variable fails, because the walk only ever looks in the facts: "Could not resolve template '{{ some_var }}' from facts for <host>". - A mixed literal and template such as "vlan{{ vlan_id }}" is passed through verbatim, because re.match requires "{{" at offset 0, and the lookup then asks for a fact named ansible_vlan{{ vlan_id }}. - The regex is unanchored, so "{{ base }}.100" matches only the leading expression and silently discards the ".100" tail, resolving to the wrong interface -- a wrong answer rather than an error. - Filters, hostvars lookups and defaults are not evaluated at all. Use resolve_in_host_context() instead, so Ansible does the templating in the host's own variable context. This is what osism/defaults all/README.md ("Consuming these values from code") prescribes for external consumers, and it makes the supported set "whatever Jinja2 supports" rather than a list of anticipated shapes. The whole resolver goes away, along with the subsequent walk from interface name to ansible_<name> to ipv4.address, since the expression covers all of it. The resolved value is validated as an IPv4 address before use. The return code already reports a templating failure, but a value that comes back looking nothing like an address must not be passed on as one either. The integration case for an internal_interface that points at an inventory variable was marked xfail(strict) when it was added, because the resolver could not resolve it. It passes now, so the marker goes: with strict set, leaving it would fail the suite on the unexpected pass. That is the demonstration this change needed -- the shapes that already worked are still covered by the same tests, and the one that did not now passes against real Ansible rather than against a mock. The tests for the deleted resolver go with it: Jinja2 traversal, the non-string and non-dict cases, the interface-name to fact-key mapping and the ipv4 extraction all tested behaviour that is now Ansible's. What replaces them asserts the contract that remains -- that the expression and the cached facts are handed over unchanged, that a resolution failure surfaces Ansible's own message, and that a non-address result is refused. Verified against ansible-core 2.18.9 and 2.19.11 with the reported variable shape ("{{ vlan_var }}" where vlan_var is itself "vlan{{ id }}"), a dotted interface name, a dashed one, and the fact-derived shape the old resolver supported, plus a missing fact. One behaviour change worth noting for review: a missing internal_interface is now reported through Ansible's undefined-variable message rather than a dedicated one. osism status rabbitmq shares the helper and is fixed with it. Assisted-by: Claude:claude-opus-5 Signed-off-by: Roger Luethi <luethi@osism.tech>
1 parent f38effd commit f1875b4

3 files changed

Lines changed: 131 additions & 135 deletions

File tree

osism/utils/rabbitmq.py

Lines changed: 34 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
# SPDX-License-Identifier: Apache-2.0
22

3+
import ipaddress
34
import json
45
import os
5-
import re
66
import subprocess
77

88
from loguru import logger
99

10-
from osism.utils.inventory import get_hosts_from_inventory, get_inventory_path
10+
from osism.utils.inventory import (
11+
HostContextResolutionError,
12+
get_hosts_from_inventory,
13+
get_inventory_path,
14+
resolve_in_host_context,
15+
)
16+
17+
# The node's internal address. Ansible names interface facts with "-" replaced
18+
# by "_" and dots left alone (PrefixFactNamespace._underscore), so "br-ex" is
19+
# ansible_br_ex while "bond0.100" is ansible_bond0.100.
20+
INTERNAL_ADDRESS_EXPRESSION = (
21+
"hostvars[inventory_hostname]"
22+
"['ansible_' + (internal_interface | replace('-', '_'))]"
23+
"['ipv4']['address']"
24+
)
1125

1226

1327
def get_rabbitmq_node_addresses():
@@ -51,73 +65,31 @@ def get_rabbitmq_node_addresses():
5165

5266
facts = json.loads(facts_data)
5367

54-
# Get hostvars for this host to find internal_interface
68+
# Resolve internal_interface and the address it carries in one
69+
# templated lookup, so that any Jinja2 shape works -- not just
70+
# the ones a hand-written resolver anticipated.
5571
hostvar_inventory_path = get_inventory_path(
5672
"/ansible/inventory/hosts.yml", prefer_minified=False
5773
)
58-
result = subprocess.check_output(
59-
f"ansible-inventory -i {hostvar_inventory_path} --host {host}",
60-
shell=True,
61-
stderr=subprocess.DEVNULL,
62-
)
63-
hostvars = json.loads(result)
64-
65-
internal_interface_raw = hostvars.get("internal_interface")
66-
if not internal_interface_raw:
67-
logger.error(f"internal_interface not found in hostvars for {host}")
68-
continue
69-
70-
# Resolve Jinja2 template if present (e.g., "{{ ansible_local.testbed_network_devices.management }}")
71-
internal_interface = internal_interface_raw
72-
template_match = re.match(
73-
r"\{\{\s*(.+?)\s*\}\}", internal_interface_raw
74-
)
75-
if template_match:
76-
path = template_match.group(1).strip()
77-
parts = path.split(".")
78-
value = facts
79-
for part in parts:
80-
if isinstance(value, dict):
81-
value = value.get(part)
82-
else:
83-
value = None
84-
break
85-
if value and isinstance(value, str):
86-
internal_interface = value
87-
else:
88-
logger.error(
89-
f"Could not resolve template '{internal_interface_raw}' from facts for {host}"
90-
)
91-
continue
92-
93-
logger.debug(f"Internal interface for {host}: {internal_interface}")
94-
95-
# Look for the interface in ansible facts. Ansible replaces "-"
96-
# with "_" in fact names and leaves dots alone
97-
# (PrefixFactNamespace._underscore), so "br-ex" is
98-
# ansible_br_ex while "bond0.100" is ansible_bond0.100.
99-
normalized_interface = internal_interface.replace("-", "_")
100-
interface_key = f"ansible_{normalized_interface}"
101-
102-
interface_facts = facts.get(interface_key)
103-
if not interface_facts:
104-
logger.error(
105-
f"Interface {internal_interface} ({interface_key}) not found in ansible facts for {host}"
106-
)
107-
continue
108-
109-
# Get IPv4 address
110-
ipv4_info = interface_facts.get("ipv4")
111-
if not ipv4_info:
112-
logger.error(
113-
f"No IPv4 address found for interface {internal_interface} on {host}"
74+
try:
75+
ipv4_address = resolve_in_host_context(
76+
host,
77+
INTERNAL_ADDRESS_EXPRESSION,
78+
hostvar_inventory_path,
79+
facts=facts,
11480
)
81+
except HostContextResolutionError as exc:
82+
logger.error(f"Could not resolve address for {host}: {exc}")
11583
continue
11684

117-
ipv4_address = ipv4_info.get("address")
118-
if not ipv4_address:
85+
# A templating failure is reported by the return code, but a
86+
# module that returns a non-address string must not be trusted
87+
# either -- validate rather than pass it on as an address.
88+
try:
89+
ipaddress.IPv4Address(ipv4_address)
90+
except ValueError:
11991
logger.error(
120-
f"No IPv4 address found for interface {internal_interface} on {host}"
92+
f"Resolved address for {host} is not an IPv4 address: {ipv4_address!r}"
12193
)
12294
continue
12395

tests/integration/test_rabbitmq_addresses.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -134,15 +134,11 @@ def test_dashed_interface_name(scenario):
134134
assert rabbitmq.get_rabbitmq_node_addresses() == [("10.74.34.13", host)]
135135

136136

137-
@pytest.mark.xfail(
138-
strict=True,
139-
reason="internal_interface pointing at an inventory variable is not resolved; "
140-
"the resolver only walks dotted paths through the facts (osism/issues#1425)",
141-
)
142137
def test_interface_from_inventory_variable(scenario):
143-
# The shape reported by a client: internal_interface refers to an inventory
144-
# variable, which is itself a literal plus a template. Nothing here is a
145-
# fact, so a facts-only walk cannot resolve it.
138+
# The shape reported in osism/issues#1425: internal_interface refers to an
139+
# inventory variable, which is itself a literal plus a template. Nothing
140+
# here is a fact, which is why resolving it needs Ansible's templating
141+
# rather than a walk through the facts. Marked xfail until that landed.
146142
host = scenario(
147143
"ctl5",
148144
{

0 commit comments

Comments
 (0)