diff --git a/README.md b/README.md index dea70df..618756c 100644 --- a/README.md +++ b/README.md @@ -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//templates/...` - Variables live in: - single-site: `roles//defaults/main.yml` - multi-site: `inventory/host_vars//.yml` -You can force it on with `--jinjaturtle` or disable with `--no-jinjaturtle`. +For Salt: +- Templates live in `states/roles//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`. --- diff --git a/enroll/manifest.py b/enroll/manifest.py index 8e2e916..c96a8dd 100644 --- a/enroll/manifest.py +++ b/enroll/manifest.py @@ -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( diff --git a/enroll/salt.py b/enroll/salt.py index 606c3fc..6415431 100644 --- a/enroll/salt.py +++ b/enroll/salt.py @@ -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() diff --git a/tests/test_manifest_salt.py b/tests/test_manifest_salt.py index b912cdb..cdbf707 100644 --- a/tests/test_manifest_salt.py +++ b/tests/test_manifest_salt.py @@ -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}