Fix configuration dump with ansible-core 2.19+

In ansible-core 2.19 and later, HostVarsVars templates variables in a
context that does not include the hostvars variable, and treats
undefined variables as fatal. As a result, kayobe configuration dump
failed for host variables that reference hostvars, for example via the
net_* filters, or that reference an undefined variable, such as
ansible_facts when facts are not gathered.

Add a kayobe_config_dump filter that resolves a host's raw variables in
the current templating context using the public Templar API, with
undefined variables tolerated. Values that cannot be resolved are
preserved in their raw form, and values that cannot be serialized to
YAML are converted to strings. Use the filter for both the full dump
and single variable dumps.

Closes-Bug: #2166964

Change-Id: Ia593472e28f5c79f153dd9c772c9a2aa3f1a0438
Signed-off-by: Will Szumski <will@stackhpc.com>
This commit is contained in:
Will Szumski committed 2026-09-10 13:47:30 +00:00
1 parent 9ff5475ae2
commit 4fd793c381
5 files changed
+305 -2

No files matched your search

+6 -2
View File
@@ -28,14 +28,18 @@
- name: Write host config to file
delegate_to: localhost
copy:
content: "{{ hostvars[inventory_hostname] | to_nice_yaml }}"
content: >-
{{ hostvars[inventory_hostname] | kayobe_config_dump
| to_nice_yaml }}
dest: "{{ dump_path }}/{{ inventory_hostname }}.yml"
when: dump_var_name is not defined
- name: Write host variable to file
delegate_to: localhost
copy:
content: "{{ hostvars[inventory_hostname][dump_var_name] | to_nice_yaml }}"
content: >-
{{ hostvars[inventory_hostname]
| kayobe_config_dump(dump_var_name) | to_nice_yaml }}
dest: "{{ dump_path }}/{{ inventory_hostname }}.yml"
when: dump_var_name is defined
+22
View File
@@ -0,0 +1,22 @@
# Copyright (c) 2026 StackHPC Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from kayobe.plugins.filter import config_dump
class FilterModule(object):
"""Configuration dump filters."""
def filters(self):
return config_dump.get_filters()
+122
View File
@@ -0,0 +1,122 @@
# Copyright (c) 2026 StackHPC Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import jinja2
from ansible import errors
from ansible.parsing.dataloader import DataLoader
from ansible.template import Templar
from ansible.utils.display import Display
try:
from ansible.module_utils._internal._datatag._tags import Deprecated
except ImportError: # pragma: no cover
# Older ansible-core versions do not tag top-level facts as deprecated.
Deprecated = None
display = Display()
def _make_serializable(value):
"""Recursively convert values that cannot be serialized to YAML.
:param value: value to convert.
:returns: a value that can be serialized to YAML.
"""
if isinstance(value, dict):
return {k: _make_serializable(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_make_serializable(v) for v in value]
if value is None or isinstance(value, (str, bytes, int, float, bool)):
return value
return str(value)
def _resolve(templar, name, value):
"""Resolve a raw Ansible variable in the current templating context.
Top-level facts are tagged as deprecated by Ansible. They are already
resolved, but may have been overridden by a user-provided template, so
attempt to resolve them. If that fails, emit a warning and preserve the
raw value.
:param templar: an Ansible Templar with the current context's variables.
:param name: name of the variable being resolved.
:param value: raw variable value to resolve.
:returns: the resolved value, or the raw value as a string if it could not
be resolved, for example because it references an undefined
variable.
"""
if Deprecated is not None and Deprecated.is_tagged_on(value):
value = Deprecated.untag(value)
try:
return templar.template(value, fail_on_undefined=False)
except Exception:
display.warning(
"Failed to resolve variable '%s'. It references a deprecated "
"top-level fact; use ansible_facts instead." % name)
return value
try:
return templar.template(value, fail_on_undefined=False)
except Exception:
# Preserve variables that cannot be resolved, such as those that
# reference omitted values. Casting to a plain string ensures that the
# value is not templated again by the caller.
return "%s" % value
@jinja2.pass_context
def kayobe_config_dump(context, hostvars, var_name=None):
"""Return the resolved Ansible variables for a host.
This is used by the ``kayobe configuration dump`` command. It is not
possible to simply serialize the result of ``hostvars[host]`` because in
ansible-core >= 2.19 ``HostVarsVars`` templates variables in a context that
does not include the ``hostvars`` variable, and fails on undefined
variables. Instead, use the raw variables and template them in the current
context.
:param context: a Jinja2 Context object.
:param hostvars: Ansible host variables for a host, typically
``hostvars[inventory_hostname]``.
:param var_name: optional name of a single variable to return. If not
specified, a dict of all variables is returned.
:returns: a dict mapping variable names to resolved values, or a single
resolved value if ``var_name`` is specified.
:raises: ansible.errors.AnsibleFilterError
"""
templar = Templar(loader=DataLoader(), variables=context.get_all())
# HostVarsVars exposes its raw variables via _vars. There is no public API
# for accessing them without templating.
variables = getattr(hostvars, '_vars', hostvars)
if var_name is not None:
if var_name not in variables:
inventory_hostname = context.get('inventory_hostname')
raise errors.AnsibleFilterError(
"Variable '%s' not found for host '%s'" %
(var_name, inventory_hostname))
return _make_serializable(
_resolve(templar, var_name, variables[var_name]))
return {
name: _make_serializable(_resolve(templar, name, value))
for name, value in variables.items()
}
def get_filters():
return {
'kayobe_config_dump': kayobe_config_dump,
}
@@ -0,0 +1,147 @@
# Copyright (c) 2026 StackHPC Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import unittest
from unittest import mock
import jinja2
from ansible._internal._datatag._tags import TrustedAsTemplate
from ansible import errors
from ansible.module_utils._internal._datatag._tags import Deprecated
from ansible.module_utils._internal._datatag import AnsibleTagHelper
from ansible.parsing.dataloader import DataLoader
from kayobe.plugins.filter import config_dump
class FakeHostVarsVars(object):
"""A minimal stand-in for Ansible's HostVarsVars."""
def __init__(self, variables):
self._vars = variables
def _trust(value):
"""Mark a variable as a trusted template, as the inventory does."""
if isinstance(value, str):
return AnsibleTagHelper.tag(value, TrustedAsTemplate())
return value
def _deprecate(value):
"""Mark a value as deprecated, as top-level facts are."""
return Deprecated(msg='deprecated', version='2.24').tag(value)
class TestConfigDump(unittest.TestCase):
maxDiff = None
def setUp(self):
# Bandit complains about Jinja2 autoescaping without nosec.
self.env = jinja2.Environment() # nosec
self.hostvars_vars = DataLoader().load("""
inventory_hostname: host1
plain: hello
# A variable referencing hostvars, as Kayobe's network interface variables
# do.
self_ref: >-
{{ 'a' if 'ansible_dummy1' in
hostvars[inventory_hostname] else [] }}
# A variable referencing an undefined variable.
undefined_nested: "{{ ansible_facts.architecture }}"
# A variable resolving to the omit sentinel.
omit_var: "{{ omit }}"
a_dict:
key: value
a_list:
- a
- b
""")
for key in ("self_ref", "undefined_nested", "omit_var"):
self.hostvars_vars[key] = _trust(self.hostvars_vars[key])
# The templating context must provide the hostvars variable, but the
# host's variables themselves do not include it.
context_vars = dict(self.hostvars_vars)
context_vars["hostvars"] = {"host1": self.hostvars_vars}
self.context = self._make_context(context_vars)
self.hostvars = FakeHostVarsVars(self.hostvars_vars)
def _make_context(self, parent):
return self.env.context_class(
self.env, parent=parent, name='dummy', blocks={})
def test_kayobe_config_dump(self):
result = config_dump.kayobe_config_dump(self.context, self.hostvars)
self.assertEqual("hello", result["plain"])
# Variables referencing hostvars are resolved.
self.assertEqual([], result["self_ref"])
# Variables referencing undefined variables are preserved.
self.assertEqual("{{ ansible_facts.architecture }}",
result["undefined_nested"])
# Omitted variables are preserved.
self.assertEqual("{{ omit }}", result["omit_var"])
self.assertEqual({"key": "value"}, result["a_dict"])
self.assertEqual(["a", "b"], result["a_list"])
def test_kayobe_config_dump_var_name(self):
result = config_dump.kayobe_config_dump(
self.context, self.hostvars, "self_ref")
self.assertEqual([], result)
def test_kayobe_config_dump_var_name_missing(self):
self.assertRaises(
errors.AnsibleFilterError,
config_dump.kayobe_config_dump,
self.context, self.hostvars, "does_not_exist")
def test_kayobe_config_dump_plain_dict(self):
variables = {"plain": "hello"}
result = config_dump.kayobe_config_dump(self.context, variables)
self.assertEqual("hello", result["plain"])
def test_kayobe_config_dump_top_level_facts(self):
variables = {
"inventory_hostname": "host1",
# A top-level fact, as injected when inject_facts_as_vars is
# enabled. It is deprecated, but is still templated, for example
# because a user may have overridden it.
"ansible_hostname": _trust(
_deprecate("{{ 'resolved-host1' }}")),
}
context_vars = dict(variables)
context_vars["hostvars"] = {"host1": variables}
context = self._make_context(context_vars)
hostvars = FakeHostVarsVars(variables)
result = config_dump.kayobe_config_dump(context, hostvars)
self.assertEqual("resolved-host1", result["ansible_hostname"])
@mock.patch.object(config_dump.display, 'warning')
def test_kayobe_config_dump_deprecated_fact_unresolved(self, mock_warning):
variables = {
"inventory_hostname": "host1",
# A deprecated top-level fact that cannot be templated.
"ansible_hostname": _trust(_deprecate("{{ omit }}")),
}
context_vars = dict(variables)
context_vars["hostvars"] = {"host1": variables}
context = self._make_context(context_vars)
hostvars = FakeHostVarsVars(variables)
result = config_dump.kayobe_config_dump(context, hostvars)
self.assertTrue(mock_warning.called)
self.assertEqual("{{ omit }}", result["ansible_hostname"])
@@ -0,0 +1,8 @@
---
fixes:
- |
Fixes an issue where ``kayobe configuration dump`` would fail with
ansible-core 2.19 or later. Variables that reference ``hostvars`` or
undefined variables are now resolved correctly, and variables that cannot
be resolved are included in the dump in their raw form. `LP#2166964
<https://bugs.launchpad.net/kayobe/+bug/2166964>`__