Oh, Salt now works with JinjaTurtle :)
All checks were successful
CI / test (push) Successful in 19m36s
Lint / test (push) Successful in 45s

This commit is contained in:
Miguel Jacq 2026-06-18 20:38:50 +10:00
parent adfeb21d4b
commit 05b2875c17
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
4 changed files with 228 additions and 6 deletions

View file

@ -292,16 +292,22 @@ If you intend to keep harvests/manifests long-term (especially in git), strongly
---
## JinjaTurtle integration (both modes)
## JinjaTurtle integration
If [JinjaTurtle](https://git.mig5.net/mig5/jinjaturtle) is installed, `enroll` can generate Jinja2 templates for ini/json/xml/toml-style config.
If [JinjaTurtle](https://git.mig5.net/mig5/jinjaturtle) is installed, `enroll` can generate Jinja2 templates for ini/json/xml/toml-style config in renderers that consume Jinja templates.
For Ansible:
- Templates live in `roles/<role>/templates/...`
- Variables live in:
- single-site: `roles/<role>/defaults/main.yml`
- multi-site: `inventory/host_vars/<fqdn>/<role>.yml`
You can force it on with `--jinjaturtle` or disable with `--no-jinjaturtle`.
For Salt:
- Templates live in `states/roles/<role>/templates/...`
- `file.managed` uses `template: jinja` with per-file `context` values
- In `--fqdn` mode, template context values are written to pillar with the file metadata
Puppet output does not use JinjaTurtle templates. You can force template generation on with `--jinjaturtle` or disable it with `--no-jinjaturtle`.
---

View file

@ -217,6 +217,7 @@ def manifest(
out,
fqdn=fqdn,
no_common_roles=no_common_roles,
jinjaturtle=jinjaturtle,
)
else:
manifest_ansible_from_bundle_dir(
@ -252,6 +253,7 @@ def manifest(
str(tmp_out),
fqdn=fqdn,
no_common_roles=no_common_roles,
jinjaturtle=jinjaturtle,
)
else:
manifest_ansible_from_bundle_dir(

View file

@ -6,7 +6,7 @@ import re
import shlex
import shutil
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
import yaml
@ -17,6 +17,12 @@ from .cm import (
role_order_key,
section_label_for_packages,
)
from .jinjaturtle import (
can_jinjify_path,
find_jinjaturtle_cmd,
infer_other_formats,
run_jinjaturtle,
)
from .state import inventory_packages_from_state, roles_from_state
@ -134,6 +140,9 @@ class SaltRole(CMModule):
artifact_role: str,
role_files_dir: Path,
file_prefix: Optional[str] = None,
jt_exe: Optional[str] = None,
jt_enabled: bool = False,
overwrite_templates: bool = True,
) -> None:
for d in self.managed_dirs_from_snapshot(snap):
path = str(d.get("path") or "").strip()
@ -151,6 +160,32 @@ class SaltRole(CMModule):
src_rel = str(mf.get("src_rel") or "").strip()
if not path or not src_rel:
continue
template = _jinjify_managed_file(
bundle_dir,
artifact_role,
src_rel,
path,
role_files_dir.parent,
jt_exe=jt_exe,
jt_enabled=jt_enabled,
overwrite_templates=overwrite_templates,
)
if template is not None:
tmpl_rel, context = template
self.add_managed_file(
path,
user=mf.get("owner") or "root",
group=mf.get("group") or "root",
mode=mf.get("mode") or "0644",
source=_template_source_uri(self.module_name, tmpl_rel),
template="jinja",
context=context,
makedirs=True,
reason=mf.get("reason") or "managed_file",
)
continue
role_rel = _copy_artifact(
bundle_dir,
artifact_role,
@ -324,6 +359,69 @@ def _source_uri(module_name: str, role_rel: str) -> str:
return f"salt://roles/{module_name}/files/{role_rel}"
def _template_source_uri(module_name: str, tmpl_rel: str) -> str:
return f"salt://roles/{module_name}/templates/{tmpl_rel}"
def _yaml_load_mapping(text: str) -> Dict[str, Any]:
try:
obj = yaml.safe_load(text)
except Exception:
return {}
return obj if isinstance(obj, dict) else {}
def _resolve_jinjaturtle_mode(jinjaturtle: str) -> Tuple[Optional[str], bool]:
jt_exe = find_jinjaturtle_cmd()
if jinjaturtle not in {"auto", "on", "off"}:
raise ValueError("jinjaturtle must be one of: auto, on, off")
if jinjaturtle == "on":
if not jt_exe:
raise RuntimeError("jinjaturtle requested but not found on PATH")
return jt_exe, True
if jinjaturtle == "auto":
return jt_exe, jt_exe is not None
return jt_exe, False
def _jinjify_managed_file(
bundle_dir: str,
artifact_role: str,
src_rel: str,
dest_path: str,
role_dir: Path,
*,
jt_exe: Optional[str],
jt_enabled: bool,
overwrite_templates: bool,
) -> Optional[Tuple[str, Dict[str, Any]]]:
if not (jt_enabled and jt_exe and can_jinjify_path(dest_path)):
return None
artifact_path = Path(bundle_dir) / "artifacts" / artifact_role / src_rel
if not artifact_path.is_file():
return None
try:
result = run_jinjaturtle(
jt_exe,
str(artifact_path),
role_name=artifact_role,
force_format=infer_other_formats(dest_path),
)
except Exception:
return None # nosec - best-effort template generation
context = _yaml_load_mapping(result.vars_text)
tmpl_rel = Path(src_rel).as_posix() + ".j2"
tmpl_dst = role_dir / "templates" / tmpl_rel
if overwrite_templates or not tmpl_dst.exists():
tmpl_dst.parent.mkdir(parents=True, exist_ok=True)
tmpl_dst.write_text(result.template_text, encoding="utf-8")
return tmpl_rel, context
def _node_file_prefix(fqdn: str) -> str:
name = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(fqdn or "").strip())
name = name.strip("._-") or "node"
@ -365,6 +463,8 @@ def _collect_salt_roles(
*,
fqdn: Optional[str] = None,
no_common_roles: bool = False,
jt_exe: Optional[str] = None,
jt_enabled: bool = False,
) -> List[SaltRole]:
roles = roles_from_state(state)
inventory_packages = inventory_packages_from_state(state)
@ -397,6 +497,9 @@ def _collect_salt_roles(
artifact_role=str(snap.get("role_name") or key),
role_files_dir=states_dir / "roles" / srole.module_name / "files",
file_prefix=node_file_prefix,
jt_exe=jt_exe,
jt_enabled=jt_enabled,
overwrite_templates=not bool(fqdn),
)
users_snap = roles.get("users") or {}
@ -412,6 +515,9 @@ def _collect_salt_roles(
artifact_role=str(users_snap.get("role_name") or "users"),
role_files_dir=states_dir / "roles" / srole.module_name / "files",
file_prefix=node_file_prefix,
jt_exe=jt_exe,
jt_enabled=jt_enabled,
overwrite_templates=not bool(fqdn),
)
for svc in roles.get("services", []) or []:
@ -443,6 +549,9 @@ def _collect_salt_roles(
artifact_role=str(svc.get("role_name") or original_role_name),
role_files_dir=states_dir / "roles" / srole.module_name / "files",
file_prefix=node_file_prefix,
jt_exe=jt_exe,
jt_enabled=jt_enabled,
overwrite_templates=not bool(fqdn),
)
for pkg in roles.get("packages", []) or []:
@ -467,6 +576,9 @@ def _collect_salt_roles(
artifact_role=str(pkg.get("role_name") or original_role_name),
role_files_dir=states_dir / "roles" / srole.module_name / "files",
file_prefix=node_file_prefix,
jt_exe=jt_exe,
jt_enabled=jt_enabled,
overwrite_templates=not bool(fqdn),
)
container_images = roles.get("container_images") or {}
@ -503,6 +615,22 @@ def _collect_salt_roles(
return [r for r in salt_roles if r.has_resources()]
def _append_yaml_value(lines: List[str], key: str, value: Any, *, indent: int) -> None:
prefix = " " * indent
if isinstance(value, dict):
dumped = yaml.safe_dump(
value, sort_keys=True, default_flow_style=False
).rstrip()
if not dumped:
lines.append(f"{prefix}- {key}: {{}}")
return
lines.append(f"{prefix}- {key}:")
for line in dumped.splitlines():
lines.append(f"{prefix} {line}")
return
lines.append(f"{prefix}- {key}: {_yaml_quote(value)}")
def _render_static_role(srole: SaltRole) -> str:
lines: List[str] = ["# Generated by Enroll from harvest state.", ""]
@ -576,9 +704,13 @@ def _render_static_role(srole: SaltRole) -> str:
f" - group: {_yaml_quote(attrs.get('group') or 'root')}",
f" - mode: {_yaml_quote(str(attrs.get('mode') or '0644'))}",
" - makedirs: true",
"",
]
)
if attrs.get("template"):
lines.append(f" - template: {_yaml_quote(attrs.get('template'))}")
if attrs.get("context"):
_append_yaml_value(lines, "context", attrs.get("context"), indent=4)
lines.append("")
for path, attrs in sorted(srole.links.items()):
lines.extend(
@ -731,6 +863,10 @@ def _role_pillar_values(srole: SaltRole) -> Dict[str, Any]:
"group": attrs.get("group") or "root",
"mode": str(attrs.get("mode") or "0644"),
"makedirs": True,
**(
{"template": attrs.get("template")} if attrs.get("template") else {}
),
**({"context": attrs.get("context")} if attrs.get("context") else {}),
}
for path, attrs in sorted(srole.files.items())
}
@ -826,6 +962,12 @@ def _render_pillar_role(srole: SaltRole) -> str:
" - group: {{ attrs.get('group', 'root')|yaml_dquote }}",
" - mode: {{ attrs.get('mode', '0644')|string|yaml_dquote }}",
" - makedirs: {{ attrs.get('makedirs', True)|yaml_encode }}",
"{% if attrs.get('template') %}",
" - template: {{ attrs.get('template')|yaml_dquote }}",
"{% endif %}",
"{% if attrs.get('context') %}",
" - context: {{ attrs.get('context')|yaml_encode }}",
"{% endif %}",
"{% endfor %}",
"",
"{% for path, attrs in role.get('links', {}).items() %}",
@ -1069,7 +1211,7 @@ This Salt target reuses the existing harvest state without changing harvesting b
## Current limitations
- Flatpak, Snap, and live firewall runtime snapshots are listed as notes when present rather than rendered as Salt states.
- JinjaTurtle templating is currently Ansible-oriented and is not applied to Salt output.
- JinjaTurtle templating is applied on a best-effort basis for file formats it recognises; unrecognised files are copied literally.
- Review generated resources before applying them broadly across unlike hosts.
## Notes
@ -1088,11 +1230,13 @@ class SaltManifestRenderer:
*,
fqdn: Optional[str] = None,
no_common_roles: bool = False,
jinjaturtle: str = "auto",
) -> None:
self.bundle_dir = bundle_dir
self.out_dir = out_dir
self.fqdn = fqdn
self.no_common_roles = no_common_roles
self.jt_exe, self.jt_enabled = _resolve_jinjaturtle_mode(jinjaturtle)
def render(self) -> None:
state = SaltRole.load_state(self.bundle_dir)
@ -1114,6 +1258,8 @@ class SaltManifestRenderer:
states_dir,
fqdn=self.fqdn,
no_common_roles=self.no_common_roles,
jt_exe=self.jt_exe,
jt_enabled=self.jt_enabled,
)
for srole in salt_roles:
@ -1153,10 +1299,12 @@ def manifest_from_bundle_dir(
*,
fqdn: Optional[str] = None,
no_common_roles: bool = False,
jinjaturtle: str = "auto",
) -> None:
SaltManifestRenderer(
bundle_dir,
out_dir,
fqdn=fqdn,
no_common_roles=no_common_roles,
jinjaturtle=jinjaturtle,
).render()

View file

@ -466,3 +466,69 @@ def test_manifest_salt_renders_container_images_in_single_and_fqdn_modes(
assert "{{.Id}}" not in pillar_text
assert "sed -n" in pillar_text
assert "podman pull" in pillar_text
def test_manifest_salt_uses_jinjaturtle_templates(monkeypatch, tmp_path: Path):
from enroll import salt as salt_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(
salt_mod, "find_jinjaturtle_cmd", lambda: "/usr/bin/jinjaturtle"
)
monkeypatch.setattr(salt_mod, "can_jinjify_path", lambda _path: True)
def fake_run_jinjaturtle(
jt_exe: str, src_path: str, *, role_name: str, force_format=None
):
assert jt_exe == "/usr/bin/jinjaturtle"
assert role_name == "foo"
assert src_path.endswith("artifacts/foo/etc/foo.conf")
return JinjifyResult(
template_text="setting = {{ foo_setting }}\n",
vars_text="foo_setting: true\n",
)
monkeypatch.setattr(salt_mod, "run_jinjaturtle", fake_run_jinjaturtle)
manifest.manifest(str(bundle), str(out), target="salt", jinjaturtle="on")
role_dir = out / "states" / "roles" / "net"
assert (role_dir / "templates" / "etc" / "foo.conf.j2").read_text(
encoding="utf-8"
) == "setting = {{ foo_setting }}\n"
assert not (role_dir / "files" / "etc" / "foo.conf").exists()
sls = (role_dir / "init.sls").read_text(encoding="utf-8")
assert 'source: "salt://roles/net/templates/etc/foo.conf.j2"' in sls
assert 'template: "jinja"' in sls
assert "foo_setting: true" in sls
fqdn_out = tmp_path / "salt-fqdn"
manifest.manifest(
str(bundle),
str(fqdn_out),
target="salt",
fqdn="node.example",
jinjaturtle="on",
)
fqdn_role_dir = fqdn_out / "states" / "roles" / "foo"
assert (fqdn_role_dir / "templates" / "etc" / "foo.conf.j2").exists()
assert not (
fqdn_role_dir / "files" / "nodes" / "node.example" / "etc" / "foo.conf"
).exists()
pillar_top = yaml.safe_load(
(fqdn_out / "pillar" / "top.sls").read_text(encoding="utf-8")
)
node_sls = pillar_top["base"]["node.example"][0]
pillar_path = fqdn_out / "pillar" / Path(*node_sls.split("."))
pillar = yaml.safe_load(pillar_path.with_suffix(".sls").read_text(encoding="utf-8"))
file_data = pillar["enroll"]["roles"]["foo"]["files"]["/etc/foo/foo.conf"]
assert file_data["source"] == "salt://roles/foo/templates/etc/foo.conf.j2"
assert file_data["template"] == "jinja"
assert file_data["context"] == {"foo_setting": True}