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/...` - Templates live in `roles/<role>/templates/...`
- Variables live in: - Variables live in:
- single-site: `roles/<role>/defaults/main.yml` - single-site: `roles/<role>/defaults/main.yml`
- multi-site: `inventory/host_vars/<fqdn>/<role>.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, out,
fqdn=fqdn, fqdn=fqdn,
no_common_roles=no_common_roles, no_common_roles=no_common_roles,
jinjaturtle=jinjaturtle,
) )
else: else:
manifest_ansible_from_bundle_dir( manifest_ansible_from_bundle_dir(
@ -252,6 +253,7 @@ def manifest(
str(tmp_out), str(tmp_out),
fqdn=fqdn, fqdn=fqdn,
no_common_roles=no_common_roles, no_common_roles=no_common_roles,
jinjaturtle=jinjaturtle,
) )
else: else:
manifest_ansible_from_bundle_dir( manifest_ansible_from_bundle_dir(

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, List, Optional from typing import Any, Dict, List, Optional, Tuple
import yaml import yaml
@ -17,6 +17,12 @@ from .cm import (
role_order_key, role_order_key,
section_label_for_packages, 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 from .state import inventory_packages_from_state, roles_from_state
@ -134,6 +140,9 @@ class SaltRole(CMModule):
artifact_role: str, artifact_role: str,
role_files_dir: Path, role_files_dir: Path,
file_prefix: Optional[str] = None, file_prefix: Optional[str] = None,
jt_exe: Optional[str] = None,
jt_enabled: bool = False,
overwrite_templates: bool = True,
) -> None: ) -> None:
for d in self.managed_dirs_from_snapshot(snap): for d in self.managed_dirs_from_snapshot(snap):
path = str(d.get("path") or "").strip() path = str(d.get("path") or "").strip()
@ -151,6 +160,32 @@ class SaltRole(CMModule):
src_rel = str(mf.get("src_rel") or "").strip() src_rel = str(mf.get("src_rel") or "").strip()
if not path or not src_rel: if not path or not src_rel:
continue 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( role_rel = _copy_artifact(
bundle_dir, bundle_dir,
artifact_role, 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}" 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: def _node_file_prefix(fqdn: str) -> str:
name = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(fqdn or "").strip()) name = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(fqdn or "").strip())
name = name.strip("._-") or "node" name = name.strip("._-") or "node"
@ -365,6 +463,8 @@ def _collect_salt_roles(
*, *,
fqdn: Optional[str] = None, fqdn: Optional[str] = None,
no_common_roles: bool = False, no_common_roles: bool = False,
jt_exe: Optional[str] = None,
jt_enabled: bool = False,
) -> List[SaltRole]: ) -> List[SaltRole]:
roles = roles_from_state(state) roles = roles_from_state(state)
inventory_packages = inventory_packages_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), artifact_role=str(snap.get("role_name") or key),
role_files_dir=states_dir / "roles" / srole.module_name / "files", role_files_dir=states_dir / "roles" / srole.module_name / "files",
file_prefix=node_file_prefix, file_prefix=node_file_prefix,
jt_exe=jt_exe,
jt_enabled=jt_enabled,
overwrite_templates=not bool(fqdn),
) )
users_snap = roles.get("users") or {} users_snap = roles.get("users") or {}
@ -412,6 +515,9 @@ def _collect_salt_roles(
artifact_role=str(users_snap.get("role_name") or "users"), artifact_role=str(users_snap.get("role_name") or "users"),
role_files_dir=states_dir / "roles" / srole.module_name / "files", role_files_dir=states_dir / "roles" / srole.module_name / "files",
file_prefix=node_file_prefix, 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 []: 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), artifact_role=str(svc.get("role_name") or original_role_name),
role_files_dir=states_dir / "roles" / srole.module_name / "files", role_files_dir=states_dir / "roles" / srole.module_name / "files",
file_prefix=node_file_prefix, 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 []: 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), artifact_role=str(pkg.get("role_name") or original_role_name),
role_files_dir=states_dir / "roles" / srole.module_name / "files", role_files_dir=states_dir / "roles" / srole.module_name / "files",
file_prefix=node_file_prefix, 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 {} 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()] 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: def _render_static_role(srole: SaltRole) -> str:
lines: List[str] = ["# Generated by Enroll from harvest state.", ""] 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" - group: {_yaml_quote(attrs.get('group') or 'root')}",
f" - mode: {_yaml_quote(str(attrs.get('mode') or '0644'))}", f" - mode: {_yaml_quote(str(attrs.get('mode') or '0644'))}",
" - makedirs: true", " - 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()): for path, attrs in sorted(srole.links.items()):
lines.extend( lines.extend(
@ -731,6 +863,10 @@ def _role_pillar_values(srole: SaltRole) -> Dict[str, Any]:
"group": attrs.get("group") or "root", "group": attrs.get("group") or "root",
"mode": str(attrs.get("mode") or "0644"), "mode": str(attrs.get("mode") or "0644"),
"makedirs": True, "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()) 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 }}", " - group: {{ attrs.get('group', 'root')|yaml_dquote }}",
" - mode: {{ attrs.get('mode', '0644')|string|yaml_dquote }}", " - mode: {{ attrs.get('mode', '0644')|string|yaml_dquote }}",
" - makedirs: {{ attrs.get('makedirs', True)|yaml_encode }}", " - 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 %}", "{% endfor %}",
"", "",
"{% for path, attrs in role.get('links', {}).items() %}", "{% 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 ## Current limitations
- Flatpak, Snap, and live firewall runtime snapshots are listed as notes when present rather than rendered as Salt states. - 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. - Review generated resources before applying them broadly across unlike hosts.
## Notes ## Notes
@ -1088,11 +1230,13 @@ class SaltManifestRenderer:
*, *,
fqdn: Optional[str] = None, fqdn: Optional[str] = None,
no_common_roles: bool = False, no_common_roles: bool = False,
jinjaturtle: str = "auto",
) -> None: ) -> None:
self.bundle_dir = bundle_dir self.bundle_dir = bundle_dir
self.out_dir = out_dir self.out_dir = out_dir
self.fqdn = fqdn self.fqdn = fqdn
self.no_common_roles = no_common_roles self.no_common_roles = no_common_roles
self.jt_exe, self.jt_enabled = _resolve_jinjaturtle_mode(jinjaturtle)
def render(self) -> None: def render(self) -> None:
state = SaltRole.load_state(self.bundle_dir) state = SaltRole.load_state(self.bundle_dir)
@ -1114,6 +1258,8 @@ class SaltManifestRenderer:
states_dir, states_dir,
fqdn=self.fqdn, fqdn=self.fqdn,
no_common_roles=self.no_common_roles, no_common_roles=self.no_common_roles,
jt_exe=self.jt_exe,
jt_enabled=self.jt_enabled,
) )
for srole in salt_roles: for srole in salt_roles:
@ -1153,10 +1299,12 @@ def manifest_from_bundle_dir(
*, *,
fqdn: Optional[str] = None, fqdn: Optional[str] = None,
no_common_roles: bool = False, no_common_roles: bool = False,
jinjaturtle: str = "auto",
) -> None: ) -> None:
SaltManifestRenderer( SaltManifestRenderer(
bundle_dir, bundle_dir,
out_dir, out_dir,
fqdn=fqdn, fqdn=fqdn,
no_common_roles=no_common_roles, no_common_roles=no_common_roles,
jinjaturtle=jinjaturtle,
).render() ).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 "{{.Id}}" not in pillar_text
assert "sed -n" in pillar_text assert "sed -n" in pillar_text
assert "podman pull" 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}