Fix salt rendering of yaml/json

This commit is contained in:
Miguel Jacq 2026-06-20 18:38:49 +10:00
parent 8cbde1423a
commit f335077e59
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
2 changed files with 143 additions and 5 deletions

View file

@ -6,7 +6,7 @@ import re
import shlex import shlex
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
import yaml import yaml
@ -276,6 +276,56 @@ def _state_id(prefix: str, value: Any, *, role: str = "") -> str:
return "_".join(parts) return "_".join(parts)
def _plain_salt_data(value: Any) -> Any:
"""Return data made from plain JSON/YAML-safe containers.
Salt's Jinja ``yaml_encode`` filter cannot represent Salt/PyYAML
``OrderedDict`` values. Normalise generated template contexts before we
write static SLS or pillar data, and before passing context to file.managed.
"""
if isinstance(value, Mapping):
return {str(key): _plain_salt_data(inner) for key, inner in value.items()}
if isinstance(value, list):
return [_plain_salt_data(item) for item in value]
if isinstance(value, tuple):
return [_plain_salt_data(item) for item in value]
if isinstance(value, set):
return sorted(_plain_salt_data(item) for item in value)
return value
_TO_JSON_FILTER_RE = re.compile(
r"{{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\s*"
r"\|\s*to_json\s*\([^)]*\)\s*}}"
)
def _saltify_jinjaturtle_template(
template_text: str, context: Dict[str, Any]
) -> Tuple[str, Dict[str, Any]]:
"""Translate JinjaTurtle's Ansible-oriented Jinja into Salt-safe Jinja.
JinjaTurtle emits Ansible's ``to_json`` filter for JSON/TOML values. Salt's
Jinja environment does not provide that filter. For ordinary generated
context variables, pre-render a JSON string and substitute a plain variable
reference. For loop-local expressions such as ``item`` or ``item.name`` we
fall back to Jinja's built-in ``tojson`` filter.
"""
salt_context = _plain_salt_data(context)
def replace(match: re.Match[str]) -> str:
expr = match.group(1)
if "." not in expr and expr in salt_context:
json_var = f"{expr}__enroll_json"
salt_context[json_var] = json.dumps(salt_context[expr], ensure_ascii=False)
return "{{ " + json_var + " }}"
return "{{ " + expr + " | tojson }}"
return _TO_JSON_FILTER_RE.sub(replace, template_text), salt_context
def _service_watch_state_ids( def _service_watch_state_ids(
role_name: str, role_name: str,
*, *,
@ -601,7 +651,24 @@ def _jinjify_managed_file(
) )
if converted is None: if converted is None:
return None return None
return converted.template_rel, converted.context
template_text, context = _saltify_jinjaturtle_template(
converted.template_text, converted.context
)
template_path = role_dir / "templates" / converted.template_rel
if template_text != converted.template_text:
existing = (
template_path.read_text(encoding="utf-8") if template_path.exists() else ""
)
if (
overwrite_templates
or not template_path.exists()
or _TO_JSON_FILTER_RE.search(existing)
):
template_path.parent.mkdir(parents=True, exist_ok=True)
template_path.write_text(template_text, encoding="utf-8")
return converted.template_rel, context
def _node_file_prefix(fqdn: str) -> str: def _node_file_prefix(fqdn: str) -> str:
@ -798,7 +865,7 @@ def _append_yaml_value(lines: List[str], key: str, value: Any, *, indent: int) -
prefix = " " * indent prefix = " " * indent
if isinstance(value, dict): if isinstance(value, dict):
dumped = yaml.safe_dump( dumped = yaml.safe_dump(
value, sort_keys=True, default_flow_style=False _plain_salt_data(value), sort_keys=True, default_flow_style=False
).rstrip() ).rstrip()
if not dumped: if not dumped:
lines.append(f"{prefix}- {key}: {{}}") lines.append(f"{prefix}- {key}: {{}}")
@ -1156,7 +1223,11 @@ def _role_pillar_values(srole: SaltRole) -> Dict[str, Any]:
**( **(
{"template": attrs.get("template")} if attrs.get("template") else {} {"template": attrs.get("template")} if attrs.get("template") else {}
), ),
**({"context": attrs.get("context")} if attrs.get("context") else {}), **(
{"context": _plain_salt_data(attrs.get("context"))}
if attrs.get("context")
else {}
),
**( **(
{"watch_in": attrs.get("watch_in")} if attrs.get("watch_in") else {} {"watch_in": attrs.get("watch_in")} if attrs.get("watch_in") else {}
), ),
@ -1277,7 +1348,7 @@ def _render_pillar_role(srole: SaltRole) -> str:
" - template: {{ attrs.get('template')|yaml_dquote }}", " - template: {{ attrs.get('template')|yaml_dquote }}",
"{% endif %}", "{% endif %}",
"{% if attrs.get('context') %}", "{% if attrs.get('context') %}",
" - context: {{ attrs.get('context')|yaml_encode }}", " - context: {{ attrs.get('context')|tojson }}",
"{% endif %}", "{% endif %}",
"{% if attrs.get('watch_in') %}", "{% if attrs.get('watch_in') %}",
" - watch_in:", " - watch_in:",

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from collections import OrderedDict
from pathlib import Path from pathlib import Path
import yaml import yaml
@ -8,6 +9,7 @@ import yaml
from enroll import manifest from enroll import manifest
from enroll.salt import ( from enroll.salt import (
SaltRole, SaltRole,
_render_pillar_role,
_render_static_role, _render_static_role,
_role_pillar_values, _role_pillar_values,
_salt_name, _salt_name,
@ -616,6 +618,71 @@ def test_manifest_salt_uses_jinjaturtle_templates(monkeypatch, tmp_path: Path):
assert file_data["context"] == {"foo_setting": True} assert file_data["context"] == {"foo_setting": True}
def test_manifest_salt_rewrites_jinjaturtle_json_filters(monkeypatch, tmp_path: Path):
import enroll.jinjaturtle as jinjaturtle_mod
from enroll.jinjaturtle import JinjifyResult
bundle = tmp_path / "bundle"
out = tmp_path / "salt"
state = _sample_state()
_write_sample_artifacts(bundle)
_write_state(bundle, state)
monkeypatch.setattr(
jinjaturtle_mod, "find_jinjaturtle_cmd", lambda: "/usr/bin/jinjaturtle"
)
monkeypatch.setattr(jinjaturtle_mod, "can_jinjify_path", lambda _path: True)
def fake_run_jinjaturtle(
jt_exe: str, src_path: str, *, role_name: str, force_format=None
):
return JinjifyResult(
template_text='{ "setting": {{ foo_setting | to_json(ensure_ascii=False) }} }\n',
vars_text='foo_setting: "alpha"\n',
)
monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle)
manifest.manifest(str(bundle), str(out), target="salt", jinjaturtle="on")
template_text = (
out / "states" / "roles" / "net" / "templates" / "etc" / "foo.conf.j2"
).read_text(encoding="utf-8")
assert "to_json" not in template_text
assert "foo_setting__enroll_json" in template_text
sls = (out / "states" / "roles" / "net" / "init.sls").read_text(encoding="utf-8")
assert "foo_setting__enroll_json:" in sls
assert '"alpha"' in sls
def test_manifest_salt_pillar_role_uses_json_for_template_context() -> None:
role = SaltRole("foo")
role.add_managed_file(
"/etc/foo.json",
source="salt://roles/foo/templates/etc/foo.json.j2",
user="root",
group="root",
mode="0644",
makedirs=True,
template="jinja",
context=OrderedDict(
[("foo_name", "alpha"), ("foo_nested", OrderedDict([("x", 1)]))]
),
)
pillar = _role_pillar_values(role)
assert type(pillar["files"]["/etc/foo.json"]["context"]) is dict
assert type(pillar["files"]["/etc/foo.json"]["context"]["foo_nested"]) is dict
rendered = _render_static_role(role)
assert "foo_nested:" in rendered
context_block = (
_render_pillar_role(role).split("context:", 1)[1].split("{% endif %}", 1)[0]
)
assert "|yaml_encode" not in context_block
assert "|tojson" in _render_pillar_role(role)
def test_manifest_salt_renders_firewall_runtime_states(tmp_path: Path): def test_manifest_salt_renders_firewall_runtime_states(tmp_path: Path):
bundle = tmp_path / "bundle" bundle = tmp_path / "bundle"
out = tmp_path / "salt" out = tmp_path / "salt"