diff --git a/.gitignore b/.gitignore index 4ef962d..73e6c37 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ dist *.csv *.html coverage.xml +*.orig +*.rej diff --git a/README.md b/README.md index 618756c..5bf7a97 100644 --- a/README.md +++ b/README.md @@ -472,7 +472,7 @@ Or with absolute paths: sudo puppet apply --modulepath /tmp/enroll-puppet/modules /tmp/enroll-puppet/manifests/site.pp --noop ``` -Docker images with registry digests are rendered as `docker::image` resources and require the Puppet environment to provide `puppetlabs-docker`; the generated module metadata records that dependency. Podman images with registry digests are rendered as guarded `podman pull` / `podman tag` exec resources. Images without `RepoDigest` are recorded in harvest state and notes, but are not converted into exact pull resources. Flatpak, Snap, and live firewall runtime snapshots are listed as notes in the generated Puppet README rather than converted into Puppet resources. +Docker images with registry digests are currently managed with `exec` statements. I know that's nasty, but the `puppetlabs-docker` module is even nastier and creates non-idempotent bash scripts for executing on every run. Worse, if you then reharvest that host that has Puppet installed, you'll get a File resource collision with that very shell script. Believe me, for the simple use case of 'make sure this Docker image is installed', this simple solution is better. ### Salt target ```bash @@ -498,9 +498,8 @@ Re-running Salt `--fqdn` output into the same directory adds or replaces that mi Docker and Podman images with registry digests are rendered as guarded `cmd.run` states that use the local `docker`/`podman` CLI directly (`pull`, `image inspect`, and `tag`). -This is because Salt Stack, in 3008, does not have proper Docker extensions that actually work. Wow. +This is because Salt Stack, in 3008, does not have proper Docker extensions that actually work. Wow. It's a bit like Puppet. Seriously, you should probably just be using Ansible. -Certain other things, like in Puppet, are not 'manifested' into Salt states unlike Ansible, at this time: these are Flatpak, Snap, and live firewall rules. ### Manifest with `--sops` ```bash diff --git a/enroll/ansible.py b/enroll/ansible.py index eb23dfe..933052f 100644 --- a/enroll/ansible.py +++ b/enroll/ansible.py @@ -747,25 +747,7 @@ def _render_firewall_runtime_tasks(var_prefix: str) -> str: owner: root group: root mode: "0600" - when: ({var_prefix}_ipset_save | default('') | length) > 0 - -- name: Flush captured ipsets before restoring members - ansible.builtin.command: - cmd: "ipset flush {{{{ item }}}}" - loop: "{{{{ {var_prefix}_ipset_sets | default([]) }}}}" - register: _enroll_ipset_flush - failed_when: false - changed_when: false - when: - - ({var_prefix}_ipset_save | default('') | length) > 0 - - {var_prefix}_sync_ipsets_exact | default(true) | bool - -- name: Restore captured ipsets - ansible.builtin.shell: "ipset restore -exist < /etc/enroll/firewall/ipset.save" - args: - executable: /bin/sh - register: _enroll_ipset_restore - changed_when: _enroll_ipset_restore.rc == 0 + notify: Restore captured ipsets when: ({var_prefix}_ipset_save | default('') | length) > 0 - name: Deploy captured IPv4 iptables snapshot @@ -780,17 +762,9 @@ def _render_firewall_runtime_tasks(var_prefix: str) -> str: owner: root group: root mode: "0600" + notify: Restore captured IPv4 iptables rules when: ({var_prefix}_iptables_v4_save | default('') | length) > 0 -- name: Restore captured IPv4 iptables rules - ansible.builtin.command: - cmd: iptables-restore /etc/enroll/firewall/iptables.v4 - register: _enroll_iptables_v4_restore - changed_when: _enroll_iptables_v4_restore.rc == 0 - when: - - ({var_prefix}_iptables_v4_save | default('') | length) > 0 - - {var_prefix}_restore_iptables | default(true) | bool - - name: Deploy captured IPv6 iptables snapshot vars: _enroll_ff: @@ -803,13 +777,50 @@ def _render_firewall_runtime_tasks(var_prefix: str) -> str: owner: root group: root mode: "0600" + notify: Restore captured IPv6 iptables rules when: ({var_prefix}_iptables_v6_save | default('') | length) > 0 +""" + + +def _render_firewall_runtime_handlers(var_prefix: str) -> str: + """Render handlers for live ipset/iptables snapshots. + + Runtime firewall snapshots are intentionally applied only when Enroll's + staged snapshot file changes. Live iptables/ipset state is volatile, and + daemons such as Docker may mutate counters/chains between configuration + management runs. Treating restore as a file-change handler keeps repeated + Ansible runs idempotent while still applying a new harvested snapshot. + """ + return f"""--- +- name: Flush captured ipsets before restoring members + ansible.builtin.command: + cmd: "ipset flush {{{{ item }}}}" + loop: "{{{{ {var_prefix}_ipset_sets | default([]) }}}}" + listen: Restore captured ipsets + register: _enroll_ipset_flush + failed_when: false + changed_when: false + when: {var_prefix}_sync_ipsets_exact | default(true) | bool + +- name: Restore captured ipsets + ansible.builtin.shell: "ipset restore -exist < /etc/enroll/firewall/ipset.save" + args: + executable: /bin/sh + listen: Restore captured ipsets + when: ({var_prefix}_ipset_save | default('') | length) > 0 + +- name: Restore captured IPv4 iptables rules + ansible.builtin.command: + cmd: iptables-restore /etc/enroll/firewall/iptables.v4 + listen: Restore captured IPv4 iptables rules + when: + - ({var_prefix}_iptables_v4_save | default('') | length) > 0 + - {var_prefix}_restore_iptables | default(true) | bool - name: Restore captured IPv6 iptables rules ansible.builtin.command: cmd: ip6tables-restore /etc/enroll/firewall/iptables.v6 - register: _enroll_iptables_v6_restore - changed_when: _enroll_iptables_v6_restore.rc == 0 + listen: Restore captured IPv6 iptables rules when: - ({var_prefix}_iptables_v6_save | default('') | length) > 0 - {var_prefix}_restore_iptables | default(true) | bool @@ -1236,9 +1247,9 @@ def _render_container_images_role( name: "{{ item.pull_ref }}" pull: not_present platform: "{{ item.platform | default(omit, true) }}" - loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'docker') | selectattr('pull_ref', 'defined') | list }}" + loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'docker') | selectattr('pull_ref') | list }}" when: - - item.pull_ref | default('') | length > 0 + - item.pull_ref | default('', true) | length > 0 become: true - name: Tag Docker images with harvested tag aliases @@ -1246,11 +1257,11 @@ def _render_container_images_role( name: "{{ item.0.pull_ref }}" repository: - "{{ item.1.ref }}" - loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'docker') | selectattr('pull_ref', 'defined') | list, 'tag_aliases', {'skip_missing': True}) }}" + loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'docker') | selectattr('pull_ref') | list, 'tag_aliases', {'skip_missing': True}) }}" when: - - item.0.pull_ref | default('') | length > 0 - - item.1.repository | default('') | length > 0 - - item.1.tag | default('') | length > 0 + - item.0.pull_ref | default('', true) | length > 0 + - item.1.repository | default('', true) | length > 0 + - item.1.tag | default('', true) | length > 0 become: true - name: Pull system Podman images by immutable registry digest @@ -1259,9 +1270,9 @@ def _render_container_images_role( state: present force: false platform: "{{ item.platform | default(omit, true) }}" - loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'podman') | rejectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list }}" + loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'podman') | rejectattr('scope', 'equalto', 'user') | selectattr('pull_ref') | list }}" when: - - item.pull_ref | default('') | length > 0 + - item.pull_ref | default('', true) | length > 0 become: true - name: Tag system Podman images with harvested tag aliases @@ -1269,10 +1280,10 @@ def _render_container_images_role( image: "{{ item.0.pull_ref }}" target_names: - "{{ item.1.ref }}" - loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'podman') | rejectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list, 'tag_aliases', {'skip_missing': True}) }}" + loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'podman') | rejectattr('scope', 'equalto', 'user') | selectattr('pull_ref') | list, 'tag_aliases', {'skip_missing': True}) }}" when: - - item.0.pull_ref | default('') | length > 0 - - item.1.ref | default('') | length > 0 + - item.0.pull_ref | default('', true) | length > 0 + - item.1.ref | default('', true) | length > 0 become: true - name: Pull user Podman images by immutable registry digest @@ -1281,10 +1292,10 @@ def _render_container_images_role( state: present force: false platform: "{{ item.platform | default(omit, true) }}" - loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'podman') | selectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list }}" + loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'podman') | selectattr('scope', 'equalto', 'user') | selectattr('pull_ref') | list }}" when: - - item.pull_ref | default('') | length > 0 - - item.user | default('') | length > 0 + - item.pull_ref | default('', true) | length > 0 + - item.user | default('', true) | length > 0 become: true become_user: "{{ item.user }}" @@ -1293,11 +1304,11 @@ def _render_container_images_role( image: "{{ item.0.pull_ref }}" target_names: - "{{ item.1.ref }}" - loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'podman') | selectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list, 'tag_aliases', {'skip_missing': True}) }}" + loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'podman') | selectattr('scope', 'equalto', 'user') | selectattr('pull_ref') | list, 'tag_aliases', {'skip_missing': True}) }}" when: - - item.0.pull_ref | default('') | length > 0 - - item.0.user | default('') | length > 0 - - item.1.ref | default('') | length > 0 + - item.0.pull_ref | default('', true) | length > 0 + - item.0.user | default('', true) | length > 0 + - item.1.ref | default('', true) | length > 0 become: true become_user: "{{ item.0.user }}" """ @@ -2539,6 +2550,31 @@ Captured parameter count: {param_count} return role +def _render_enroll_runtime_role(ctx: AnsibleManifestContext) -> str: + role = "enroll_runtime" + role_dir = os.path.join(ctx.roles_root, role) + _write_role_scaffold(role_dir) + tasks = """--- +- name: Ensure Enroll runtime directory exists + ansible.builtin.file: + path: /etc/enroll + state: directory + owner: root + group: root + mode: "0750" +""" + with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f: + f.write(tasks.rstrip() + "\n") + with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f: + f.write("---\ndependencies: []\n") + with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f: + f.write( + "# enroll_runtime\n\n" + "Generated by Enroll to hold common runtime scaffolding used by other generated roles.\n" + ) + return role + + def _render_firewall_runtime_role( ctx: AnsibleManifestContext, firewall_runtime_snapshot: Dict[str, Any], @@ -2609,6 +2645,11 @@ def _render_firewall_runtime_role( with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f: f.write(tasks.rstrip() + "\n") + with open( + os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8" + ) as f: + f.write(_render_firewall_runtime_handlers(var_prefix).rstrip() + "\n") + with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f: f.write("---\ndependencies: []\n") @@ -3091,6 +3132,9 @@ class AnsibleManifestRenderer: firewall_role = _render_firewall_runtime_role( ctx, roles.get("firewall_runtime", {}) ) + enroll_runtime_role = ( + _render_enroll_runtime_role(ctx) if firewall_role else None + ) service_roles = _render_service_roles(ctx, services_to_manifest) occupied_role_names = set(managed_roles.values()) @@ -3101,6 +3145,7 @@ class AnsibleManifestRenderer: container_role, sysctl_role, firewall_role, + enroll_runtime_role, ): if role: occupied_role_names.add(role) @@ -3127,7 +3172,7 @@ class AnsibleManifestRenderer: ordered_roles = _ordered_playbook_roles( rendered_roles, ["cron", "logrotate"] + common_tail_roles ) - for role in (sysctl_role, firewall_role): + for role in (enroll_runtime_role, sysctl_role, firewall_role): _add_role(ordered_roles, role) _write_manifest_playbook(ctx, ordered_roles) diff --git a/enroll/cm.py b/enroll/cm.py index 2799807..39306b1 100644 --- a/enroll/cm.py +++ b/enroll/cm.py @@ -197,6 +197,7 @@ def role_order_key(role: str) -> tuple[int, str]: "extra_paths": 82, "container_images": 88, "users": 90, + "enroll_runtime": 94, "sysctl": 95, "firewall_runtime": 99, } diff --git a/enroll/jinjaturtle.py b/enroll/jinjaturtle.py index 65f792f..88a64d4 100644 --- a/enroll/jinjaturtle.py +++ b/enroll/jinjaturtle.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import re import shutil import subprocess # nosec import tempfile @@ -74,6 +76,49 @@ class JinjifiedArtifact: context: Dict[str, Any] +_JINJA_EXPR_VAR_RE = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_]*)\b") +_JINJA_FOR_RE = re.compile( + r"{%\s*for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+([A-Za-z_][A-Za-z0-9_]*)\b" +) +_JINJA_SPECIAL_VARS = {"loop", "true", "false", "none", "True", "False", "None"} + + +def _find_undeclared_jinja_vars(template_text: str) -> Set[str]: + try: + from jinja2 import Environment, meta # type: ignore + + env = Environment() # nosec B701 - parsing config templates, not rendering HTML + ast = env.parse(template_text) + return set(meta.find_undeclared_variables(ast)) + except Exception: + locals_from_loops: Set[str] = set() + collection_vars: Set[str] = set() + for match in _JINJA_FOR_RE.finditer(template_text): + locals_from_loops.add(match.group(1)) + collection_vars.add(match.group(2)) + + referenced = set(_JINJA_EXPR_VAR_RE.findall(template_text)) | collection_vars + referenced -= locals_from_loops + referenced -= _JINJA_SPECIAL_VARS + return referenced + + +def missing_jinja_template_vars( + template_text: str, context: Dict[str, Any] +) -> Set[str]: + """Return variables referenced by a JinjaTurtle template but absent from vars. + + This is a defensive check for Enroll's best-effort templating path. If + JinjaTurtle ever emits a placeholder without a matching default variable, + Enroll should fall back to copying the raw harvested file rather than + generating an Ansible role that fails at apply time. + """ + + referenced = _find_undeclared_jinja_vars(template_text) + referenced -= _JINJA_SPECIAL_VARS + return {name for name in referenced if name not in context} + + def jinjify_artifact( bundle_dir: str | Path, artifact_role: str, @@ -113,6 +158,15 @@ def jinjify_artifact( template_rel = Path(src_rel).as_posix() + ".j2" template_dst = Path(template_root) / template_rel + + context = yaml_load_mapping(result.vars_text) + if missing_jinja_template_vars(result.template_text, context): + # If this role was generated into an existing output directory, avoid + # leaving an obsolete .j2 behind after falling back to a raw copy. + if overwrite_templates and template_dst.exists(): + template_dst.unlink() + return None + if overwrite_templates or not template_dst.exists(): template_dst.parent.mkdir(parents=True, exist_ok=True) template_dst.write_text(result.template_text, encoding="utf-8") @@ -121,10 +175,32 @@ def jinjify_artifact( template_rel=template_rel, template_text=result.template_text, vars_text=result.vars_text, - context=yaml_load_mapping(result.vars_text), + context=context, ) +def managed_file_var_prefix(role_name: str, src_rel: str) -> str: + """Return a JinjaTurtle-safe variable prefix for one managed file. + + JinjaTurtle's ``--role-name`` is a variable prefix. Enroll can place many + unrelated managed files in one generated role, so using only the role name + can collide for common keys such as ``enabled``, ``ignore``, or ``name``. + Include the relative artifact path when a role templates multiple files. + """ + + raw = f"{role_name}_{src_rel}" + safe = re.sub(r"[^A-Za-z0-9_]+", "_", raw).strip("_").lower() + safe = re.sub(r"_+", "_", safe) + if not safe: + safe = "managed_file" + if len(safe) > 96: + digest = hashlib.sha1( # nosec B324 + raw.encode("utf-8", errors="replace") + ).hexdigest()[:8] + safe = safe[:80].rstrip("_") + "_" + digest + return safe + + def jinjify_managed_files( bundle_dir: str | Path, artifact_role: str, @@ -145,6 +221,15 @@ def jinjify_managed_files( """ templated: Set[str] = set() vars_map: Dict[str, Any] = {} + base_role_name = role_name or artifact_role + candidates = [ + mf + for mf in managed_files + if str(mf.get("path") or "") + and str(mf.get("src_rel") or "") + and can_jinjify_path(str(mf.get("path") or "")) + ] + namespace_by_file = len(candidates) > 1 for mf in managed_files: dest_path = str(mf.get("path") or "") @@ -161,7 +246,11 @@ def jinjify_managed_files( jt_exe=jt_exe, jt_enabled=jt_enabled, overwrite_templates=overwrite_templates, - role_name=role_name or artifact_role, + role_name=( + managed_file_var_prefix(base_role_name, src_rel) + if namespace_by_file + else base_role_name + ), ) if converted is None: continue diff --git a/enroll/puppet.py b/enroll/puppet.py index 45a1db3..55a8288 100644 --- a/enroll/puppet.py +++ b/enroll/puppet.py @@ -29,9 +29,20 @@ class PuppetRole(CMModule): module_name=_puppet_name(role_name, fallback="enroll_role"), ) self.container_images: List[Dict[str, Any]] = [] + self.flatpak_remotes: List[Dict[str, Any]] = [] + self.flatpaks: List[Dict[str, Any]] = [] + self.snaps: List[Dict[str, Any]] = [] + self.firewall_runtime: Dict[str, Any] = {} def has_resources(self) -> bool: - return super().has_resources() or bool(self.container_images) + return ( + super().has_resources() + or bool(self.container_images) + or bool(self.flatpak_remotes) + or bool(self.flatpaks) + or bool(self.snaps) + or bool(self.firewall_runtime) + ) def add_package_snapshot(self, snap: Dict[str, Any]) -> None: pkg = str(snap.get("package") or "").strip() @@ -83,10 +94,108 @@ class PuppetRole(CMModule): "supplementary_groups": supplementary, } - if snap.get("user_flatpaks") or snap.get("user_flatpak_remotes"): - self.notes.append( - "Per-user Flatpak resources were detected but are not yet rendered as native Puppet resources." + home_by_user = { + str(u.get("name")): str(u.get("home") or "") + for u in (snap.get("users", []) or []) + if isinstance(u, dict) and u.get("name") + } + for remote in snap.get("user_flatpak_remotes", []) or []: + item = _normalise_flatpak_remote(remote) + user = str(item.get("user") or "").strip() + if user and not item.get("home"): + item["home"] = home_by_user.get(user) or f"/home/{user}" + if item.get("method") == "user" and item.get("name") and item.get("url"): + self.flatpak_remotes.append(_prepare_flatpak_remote(item)) + for uname, flatpaks in (snap.get("user_flatpaks", {}) or {}).items(): + user = str(uname) + for fp in flatpaks or []: + item = _normalise_flatpak_item( + fp, method="user", user=user, home=home_by_user.get(user) or None + ) + if item.get("name"): + self.flatpaks.append(_prepare_flatpak_item(item)) + + def add_flatpak_snapshot(self, snap: Dict[str, Any]) -> None: + for remote in snap.get("remotes", []) or []: + item = _normalise_flatpak_remote(remote) + if item.get("name") and item.get("url"): + self.flatpak_remotes.append(_prepare_flatpak_remote(item)) + for fp in snap.get("system_flatpaks", []) or []: + item = _normalise_flatpak_item(fp, method="system") + if item.get("name"): + self.flatpaks.append(_prepare_flatpak_item(item)) + for note in snap.get("notes", []) or []: + self.notes.append(str(note)) + + def add_snap_snapshot(self, snap: Dict[str, Any]) -> None: + for raw in snap.get("system_snaps", []) or []: + item = _normalise_snap_item(raw) + if item.get("name"): + self.snaps.append(_prepare_snap_item(item)) + for note in snap.get("notes", []) or []: + self.notes.append(str(note)) + + def add_firewall_runtime_snapshot( + self, + snap: Dict[str, Any], + *, + bundle_dir: str, + artifact_role: str, + module_files_dir: Path, + file_prefix: Optional[str] = None, + ) -> None: + self.packages.update( + str(p).strip() for p in (snap.get("packages") or []) if str(p).strip() + ) + self.add_managed_dir( + "/etc/enroll/firewall", + owner="root", + group="root", + mode="0750", + require="File['/etc/enroll']", + reason="firewall_runtime", + ) + runtime: Dict[str, Any] = {} + for key, dest_name, mode in ( + ("ipset_save", "ipset.save", "0600"), + ("iptables_v4_save", "iptables.v4", "0600"), + ("iptables_v6_save", "iptables.v6", "0600"), + ): + src_rel = str(snap.get(key) or "").strip() + if not src_rel: + continue + role_rel = _copy_artifact( + bundle_dir, + artifact_role, + src_rel, + module_files_dir, + dst_prefix=file_prefix, ) + if not role_rel: + self.notes.append( + f"Firewall runtime artifact {src_rel!r} was referenced but not found." + ) + continue + dest = f"/etc/enroll/firewall/{dest_name}" + self.add_managed_file( + dest, + owner="root", + group="root", + mode=mode, + source=_source_uri(self.module_name, role_rel), + reason="firewall_runtime", + ) + runtime[key] = dest + ipset_sets = [ + str(x).strip() for x in (snap.get("ipset_sets") or []) if str(x).strip() + ] + if ipset_sets: + runtime["ipset_sets"] = ipset_sets + if runtime: + runtime.update(_firewall_runtime_commands(runtime)) + self.firewall_runtime.update(runtime) + for note in snap.get("notes", []) or []: + self.notes.append(str(note)) def add_container_images_snapshot(self, snap: Dict[str, Any]) -> None: for raw in snap.get("images", []) or []: @@ -265,10 +374,204 @@ def _container_tag_cmd(engine: str, pull_ref: str, tag_ref: str) -> str: return f"{engine} tag {_shell_quote(pull_ref)} {_shell_quote(tag_ref)}" +def _normalise_flatpak_item( + item: Dict[str, Any], + *, + method: str, + user: Optional[str] = None, + home: Optional[str] = None, +) -> Dict[str, Any]: + out = dict(item) + out["method"] = str(out.get("method") or method or "system").strip() or "system" + if user and not out.get("user"): + out["user"] = user + if home and not out.get("home"): + out["home"] = home + ref = str(out.get("ref") or "").strip() + if ref and not out.get("name"): + out["name"] = ref.rsplit("/", 1)[-1] + name = str(out.get("name") or out.get("app_id") or "").strip() + if name: + out["name"] = name + remote = str(out.get("remote") or "").strip() + if remote: + out["remote"] = remote + branch = str(out.get("branch") or out.get("origin") or "").strip() + if branch: + out["branch"] = branch + if ref: + out["ref"] = ref + return out + + +def _normalise_flatpak_remote(item: Dict[str, Any]) -> Dict[str, Any]: + out = dict(item) + name = str(out.get("name") or out.get("remote") or "").strip() + url = str(out.get("url") or out.get("from_url") or "").strip() + method = str(out.get("method") or out.get("scope") or "system").strip() or "system" + if name: + out["name"] = name + if url: + out["url"] = url + out["method"] = "user" if method == "user" else "system" + return out + + +def _normalise_snap_item(item: Dict[str, Any]) -> Dict[str, Any]: + out = dict(item) + name = str(out.get("name") or "").strip() + if name: + out["name"] = name + channel = str(out.get("tracking") or out.get("channel") or "").strip() + if channel: + out["channel"] = channel + notes = [str(note).lower() for note in (out.get("notes") or [])] + confinement = str(out.get("confinement") or "").strip().lower() + out["classic"] = confinement == "classic" or any( + "classic" in note for note in notes + ) + out["devmode"] = any("devmode" in note or "dev mode" in note for note in notes) + out["dangerous"] = any("dangerous" in note for note in notes) + revision = str(out.get("revision") or "").strip() + if revision and not channel: + out["revision"] = revision + return out + + +def _flatpak_scope(item: Dict[str, Any]) -> str: + return "--user" if str(item.get("method") or "system") == "user" else "--system" + + +def _flatpak_home(item: Dict[str, Any]) -> Optional[str]: + user = str(item.get("user") or "").strip() + if not user: + return None + return str(item.get("home") or f"/home/{user}") + + +def _flatpak_exec_env(item: Dict[str, Any]) -> List[str]: + home = _flatpak_home(item) + if not home: + return [] + return [f"HOME={home}", f"XDG_DATA_HOME={home}/.local/share"] + + +def _flatpak_remote_exists_cmd(item: Dict[str, Any]) -> str: + return ( + f"flatpak {_flatpak_scope(item)} remote-list --columns=name " + f"| grep -Fx -- {_shell_quote(item.get('name'))}" + ) + + +def _flatpak_remote_add_cmd(item: Dict[str, Any]) -> str: + return ( + f"flatpak {_flatpak_scope(item)} remote-add --if-not-exists " + f"{_shell_quote(item.get('name'))} {_shell_quote(item.get('url'))}" + ) + + +def _flatpak_ref(item: Dict[str, Any]) -> str: + ref = str(item.get("ref") or "").strip() + if ref: + return ref + return str(item.get("name") or "").strip() + + +def _flatpak_exists_cmd(item: Dict[str, Any]) -> str: + return f"flatpak {_flatpak_scope(item)} info {_shell_quote(_flatpak_ref(item))} >/dev/null 2>&1" + + +def _flatpak_install_cmd(item: Dict[str, Any]) -> str: + args = ["flatpak", _flatpak_scope(item), "install", "-y"] + remote = str(item.get("remote") or "").strip() + if remote: + args.append(remote) + args.append(_flatpak_ref(item)) + return " ".join(_shell_quote(arg) for arg in args) + + +def _prepare_flatpak_remote(item: Dict[str, Any]) -> Dict[str, Any]: + out = dict(item) + method = str(out.get("method") or "system") + user = str(out.get("user") or "") + name = str(out.get("name") or "") + out["state_id"] = _state_title("flatpak-remote", f"{method}-{user}-{name}") + out["add_cmd"] = _flatpak_remote_add_cmd(out) + out["exists_cmd"] = _flatpak_remote_exists_cmd(out) + out["environment"] = _flatpak_exec_env(out) + return out + + +def _prepare_flatpak_item(item: Dict[str, Any]) -> Dict[str, Any]: + out = dict(item) + method = str(out.get("method") or "system") + user = str(out.get("user") or "") + ref = _flatpak_ref(out) + out["state_id"] = _state_title("flatpak", f"{method}-{user}-{ref}") + out["install_cmd"] = _flatpak_install_cmd(out) + out["exists_cmd"] = _flatpak_exists_cmd(out) + out["environment"] = _flatpak_exec_env(out) + return out + + +def _snap_exists_cmd(item: Dict[str, Any]) -> str: + return f"snap list {_shell_quote(item.get('name'))} >/dev/null 2>&1" + + +def _snap_install_cmd(item: Dict[str, Any]) -> str: + args = ["snap", "install", str(item.get("name") or "")] + channel = str(item.get("channel") or "").strip() + revision = str(item.get("revision") or "").strip() + if channel: + args.append(f"--channel={channel}") + elif revision: + args.append(f"--revision={revision}") + if item.get("classic"): + args.append("--classic") + if item.get("devmode"): + args.append("--devmode") + if item.get("dangerous"): + args.append("--dangerous") + return " ".join(_shell_quote(arg) for arg in args if str(arg)) + + +def _prepare_snap_item(item: Dict[str, Any]) -> Dict[str, Any]: + out = dict(item) + name = str(out.get("name") or "") + out["state_id"] = _state_title("snap", name) + out["install_cmd"] = _snap_install_cmd(out) + out["exists_cmd"] = _snap_exists_cmd(out) + return out + + def _pp_array(values: Iterable[Any]) -> str: return "[" + ", ".join(_pp_quote(v) for v in values) + "]" +def _puppet_exec_attrs( + command: str, + unless: str, + *, + item: Optional[Dict[str, Any]] = None, + require: Optional[str] = None, +) -> List[Tuple[str, str]]: + attrs: List[Tuple[str, str]] = [ + ("command", _pp_quote(command)), + ("unless", _pp_quote(unless)), + ("path", "['/usr/bin', '/bin']"), + ] + if item: + user = str(item.get("user") or "").strip() + if user: + attrs.append(("user", _pp_quote(user))) + env = item.get("environment") or _flatpak_exec_env(item) + if env: + attrs.append(("environment", _pp_array(env))) + if require: + attrs.append(("require", require)) + return attrs + + def _resource( lines: List[str], rtype: str, title: str, attrs: List[Tuple[str, str]] ) -> None: @@ -293,6 +596,71 @@ def _state_title(prefix: str, value: Any) -> str: return f"enroll-{prefix}-{safe}" +def _firewall_ipset_restore_cmd(path: str, sets: List[str]) -> str: + flush_parts = [f"ipset flush {_shell_quote(name)} || true" for name in sets] + flush = "; ".join(flush_parts) + restore = f"ipset restore -exist < {_shell_quote(path)}" + if flush: + return f"/bin/sh -c {_shell_quote(flush + '; ' + restore)}" + return f"/bin/sh -c {_shell_quote(restore)}" + + +def _firewall_runtime_commands(runtime: Dict[str, Any]) -> Dict[str, Any]: + out: Dict[str, Any] = {} + ipset_path = str(runtime.get("ipset_save") or "") + if ipset_path: + sets = [str(x) for x in (runtime.get("ipset_sets") or []) if str(x)] + out["ipset_restore_cmd"] = _firewall_ipset_restore_cmd(ipset_path, sets) + ipt4_path = str(runtime.get("iptables_v4_save") or "") + if ipt4_path: + out["iptables_v4_restore_cmd"] = f"iptables-restore {_shell_quote(ipt4_path)}" + ipt6_path = str(runtime.get("iptables_v6_save") or "") + if ipt6_path: + out["iptables_v6_restore_cmd"] = f"ip6tables-restore {_shell_quote(ipt6_path)}" + return out + + +def _render_firewall_runtime_execs( + lines: List[str], runtime: Dict[str, Any], *, indent: str = " " +) -> None: + specs = [ + ( + "ipset", + "ipset_save", + "ipset_restore_cmd", + "enroll-firewall-runtime-ipset-restore", + ), + ( + "iptables_v4", + "iptables_v4_save", + "iptables_v4_restore_cmd", + "enroll-firewall-runtime-iptables-v4-restore", + ), + ( + "iptables_v6", + "iptables_v6_save", + "iptables_v6_restore_cmd", + "enroll-firewall-runtime-iptables-v6-restore", + ), + ] + for _family, path_key, cmd_key, title in specs: + path = str(runtime.get(path_key) or "") + command = str(runtime.get(cmd_key) or "") + if not path or not command: + continue + attrs: List[Tuple[str, str]] = [ + ("command", _pp_quote(command)), + ("path", "['/sbin', '/usr/sbin', '/bin', '/usr/bin']"), + ("refreshonly", "true"), + ("subscribe", f"File[{_pp_quote(path)}]"), + ] + lines.append(f"{indent}exec {{ {_pp_quote(title)}:") + for key, value in attrs: + lines.append(f"{indent} {key} => {value},") + lines.append(f"{indent}}}") + lines.append("") + + def _copy_artifact( bundle_dir: str, role: str, @@ -317,23 +685,6 @@ def _source_uri(module_name: str, module_rel: str) -> str: return f"puppet:///modules/{module_name}/{module_rel}" -def _add_flatpak_snap_notes(roles: Dict[str, Any], out: Dict[str, PuppetRole]) -> None: - flatpak = roles.get("flatpak") or {} - if isinstance(flatpak, dict) and ( - flatpak.get("system_flatpaks") or flatpak.get("remotes") - ): - prole = out.setdefault("flatpak", PuppetRole("flatpak")) - prole.notes.append( - "Flatpak resources were detected but are not yet rendered as native Puppet resources." - ) - snap = roles.get("snap") or {} - if isinstance(snap, dict) and snap.get("system_snaps"): - prole = out.setdefault("snap", PuppetRole("snap")) - prole.notes.append( - "Snap resources were detected but are not yet rendered as native Puppet resources." - ) - - def _node_data_filename(fqdn: str) -> str: """Return a safe Hiera node-data filename for an FQDN/certname.""" @@ -480,15 +831,37 @@ def _collect_puppet_roles( packages = [ str(p).strip() for p in (fw.get("packages") or []) if str(p).strip() ] - if has_fw or packages: - prole = ensure_role(str(fw.get("role_name") or "firewall_runtime")) - prole.packages.update(packages) + if has_fw or packages or fw.get("notes"): if has_fw: - prole.notes.append( - "Live firewall runtime snapshots were detected but are not yet rendered as Puppet resources." + runtime_role = ensure_role("enroll_runtime") + runtime_role.add_managed_dir( + "/etc/enroll", + owner="root", + group="root", + mode="0750", + reason="enroll_runtime", ) + role_name = str(fw.get("role_name") or "firewall_runtime") + prole = ensure_role(role_name) + prole.add_firewall_runtime_snapshot( + fw, + bundle_dir=bundle_dir, + artifact_role=role_name, + module_files_dir=modules_dir / prole.module_name / "files", + file_prefix=node_file_prefix, + ) - _add_flatpak_snap_notes(roles, out) + flatpak = roles.get("flatpak") or {} + if isinstance(flatpak, dict) and ( + flatpak.get("system_flatpaks") or flatpak.get("remotes") or flatpak.get("notes") + ): + prole = ensure_role(str(flatpak.get("role_name") or "flatpak")) + prole.add_flatpak_snapshot(flatpak) + + snap = roles.get("snap") or {} + if isinstance(snap, dict) and (snap.get("system_snaps") or snap.get("notes")): + prole = ensure_role(str(snap.get("role_name") or "snap")) + prole.add_snap_snapshot(snap) puppet_roles = sorted(out.values(), key=lambda r: role_order_key(r.role_name)) resolve_catalog_conflicts(puppet_roles) @@ -549,6 +922,7 @@ def _render_role_class(prole: PuppetRole) -> str: ("owner", _pp_quote(d.get("owner") or "root")), ("group", _pp_quote(d.get("group") or "root")), ("mode", _pp_quote(d.get("mode") or "0755")), + *([("require", str(d.get("require")))] if d.get("require") else []), ], ) @@ -588,6 +962,82 @@ def _render_role_class(prole: PuppetRole) -> str: ], ) + flatpak_remote_titles: Dict[Tuple[str, str, str], str] = {} + for remote in prole.flatpak_remotes: + name = str(remote.get("name") or "").strip() + url = str(remote.get("url") or "").strip() + if not name or not url: + continue + title = str(remote.get("state_id") or _state_title("flatpak-remote", name)) + key = ( + str(remote.get("method") or "system"), + str(remote.get("user") or ""), + name, + ) + flatpak_remote_titles[key] = title + remote_user = str(remote.get("user") or "").strip() + remote_require = None + if remote_user and remote_user in prole.users: + remote_require = f"User[{_pp_quote(remote_user)}]" + _resource( + lines, + "exec", + title, + _puppet_exec_attrs( + str(remote.get("add_cmd") or _flatpak_remote_add_cmd(remote)), + str(remote.get("exists_cmd") or _flatpak_remote_exists_cmd(remote)), + item=remote, + require=remote_require, + ), + ) + + for app in prole.flatpaks: + ref = _flatpak_ref(app) + if not ref: + continue + title = str(app.get("state_id") or _state_title("flatpak", ref)) + requires: List[str] = [] + user = str(app.get("user") or "").strip() + if user: + requires.append(f"User[{_pp_quote(user)}]") + remote = str(app.get("remote") or "").strip() + if remote: + remote_title = flatpak_remote_titles.get( + (str(app.get("method") or "system"), user, remote) + ) + if remote_title: + requires.append(f"Exec[{_pp_quote(remote_title)}]") + require_expr = None + if len(requires) == 1: + require_expr = requires[0] + elif requires: + require_expr = "[" + ", ".join(requires) + "]" + _resource( + lines, + "exec", + title, + _puppet_exec_attrs( + str(app.get("install_cmd") or _flatpak_install_cmd(app)), + str(app.get("exists_cmd") or _flatpak_exists_cmd(app)), + item=app, + require=require_expr, + ), + ) + + for snap in prole.snaps: + name = str(snap.get("name") or "").strip() + if not name: + continue + _resource( + lines, + "exec", + str(snap.get("state_id") or _state_title("snap", name)), + _puppet_exec_attrs( + str(snap.get("install_cmd") or _snap_install_cmd(snap)), + str(snap.get("exists_cmd") or _snap_exists_cmd(snap)), + ), + ) + for image in prole.container_images: engine = str(image.get("engine") or "").strip() pull_ref = str(image.get("pull_ref") or "").strip() @@ -698,6 +1148,9 @@ def _render_role_class(prole: PuppetRole) -> str: ], ) + if prole.firewall_runtime: + _render_firewall_runtime_execs(lines, prole.firewall_runtime) + if has_sysctl_conf: lines.append(" if $sysctl_apply {") lines.append(" exec { 'enroll-apply-sysctl':") @@ -776,7 +1229,7 @@ def _role_hiera_values(prole: PuppetRole) -> Dict[str, Any]: path: _attrs_with_ensure( prole.dirs[path], "directory", - allowed={"owner", "group", "mode"}, + allowed={"owner", "group", "mode", "require"}, ) for path in sorted(prole.dirs) } @@ -810,8 +1263,16 @@ def _role_hiera_values(prole: PuppetRole) -> Dict[str, Any]: for name in sorted(prole.services) } + if prole.flatpak_remotes: + data[f"{prefix}flatpak_remotes"] = list(prole.flatpak_remotes) + if prole.flatpaks: + data[f"{prefix}flatpaks"] = list(prole.flatpaks) + if prole.snaps: + data[f"{prefix}snaps"] = list(prole.snaps) if prole.container_images: data[f"{prefix}container_images"] = list(prole.container_images) + if prole.firewall_runtime: + data[f"{prefix}firewall_runtime"] = dict(prole.firewall_runtime) if prole.notes: data[f"{prefix}notes"] = list(prole.notes) @@ -837,7 +1298,11 @@ def _render_hiera_role_class(prole: PuppetRole) -> str: " Hash[String, Hash] $files = {},", " Hash[String, Hash] $links = {},", " Hash[String, Hash] $services = {},", + " Array[Hash] $flatpak_remotes = [],", + " Array[Hash] $flatpaks = [],", + " Array[Hash] $snaps = [],", " Array[Hash] $container_images = [],", + " Hash $firewall_runtime = {},", " Array[String] $notes = [],", " Boolean $sysctl_apply = true,", " Boolean $sysctl_ignore_apply_errors = true,", @@ -885,6 +1350,34 @@ def _render_hiera_role_class(prole: PuppetRole) -> str: " }", " }", "", + " $flatpak_remotes.each |Integer $idx, Hash $remote| {", + " exec { $remote['state_id']:", + " command => $remote['add_cmd'],", + " unless => $remote['exists_cmd'],", + " path => ['/usr/bin', '/bin'],", + " user => $remote['user'],", + " environment => $remote['environment'],", + " }", + " }", + "", + " $flatpaks.each |Integer $idx, Hash $app| {", + " exec { $app['state_id']:", + " command => $app['install_cmd'],", + " unless => $app['exists_cmd'],", + " path => ['/usr/bin', '/bin'],", + " user => $app['user'],", + " environment => $app['environment'],", + " }", + " }", + "", + " $snaps.each |Integer $idx, Hash $snap| {", + " exec { $snap['state_id']:", + " command => $snap['install_cmd'],", + " unless => $snap['exists_cmd'],", + " path => ['/usr/bin', '/bin'],", + " }", + " }", + "", " $container_images.each |Integer $idx, Hash $image| {", " if $image['engine'] == 'docker' and $image['pull_ref'] {", ' exec { "enroll-docker-pull-${idx}":', @@ -917,6 +1410,33 @@ def _render_hiera_role_class(prole: PuppetRole) -> str: " }", " }", "", + " if $firewall_runtime['ipset_restore_cmd'] {", + " exec { 'enroll-firewall-runtime-ipset-restore':", + " command => $firewall_runtime['ipset_restore_cmd'],", + " path => ['/sbin', '/usr/sbin', '/bin', '/usr/bin'],", + " refreshonly => true,", + " subscribe => File[$firewall_runtime['ipset_save']],", + " }", + " }", + "", + " if $firewall_runtime['iptables_v4_restore_cmd'] {", + " exec { 'enroll-firewall-runtime-iptables-v4-restore':", + " command => $firewall_runtime['iptables_v4_restore_cmd'],", + " path => ['/sbin', '/usr/sbin', '/bin', '/usr/bin'],", + " refreshonly => true,", + " subscribe => File[$firewall_runtime['iptables_v4_save']],", + " }", + " }", + "", + " if $firewall_runtime['iptables_v6_restore_cmd'] {", + " exec { 'enroll-firewall-runtime-iptables-v6-restore':", + " command => $firewall_runtime['iptables_v6_restore_cmd'],", + " path => ['/sbin', '/usr/sbin', '/bin', '/usr/bin'],", + " refreshonly => true,", + " subscribe => File[$firewall_runtime['iptables_v6_save']],", + " }", + " }", + "", " if $sysctl_apply and '/etc/sysctl.d/99-enroll.conf' in $files {", " exec { 'enroll-apply-sysctl':", " command => $sysctl_ignore_apply_errors ? {", @@ -1145,7 +1665,6 @@ This Puppet target reuses the existing harvest state without changing harvesting ## Current limitations -- Flatpak, Snap, and live firewall runtime snapshots are listed as notes when present rather than rendered as Puppet resources. - JinjaTurtle templating is currently Ansible-oriented and is not applied to Puppet output. - Review generated resources before applying them broadly across unlike hosts. diff --git a/enroll/salt.py b/enroll/salt.py index 941c185..97fbe97 100644 --- a/enroll/salt.py +++ b/enroll/salt.py @@ -31,9 +31,20 @@ class SaltRole(CMModule): module_name=_salt_name(role_name, fallback="enroll_role"), ) self.container_images: List[Dict[str, Any]] = [] + self.flatpak_remotes: List[Dict[str, Any]] = [] + self.flatpaks: List[Dict[str, Any]] = [] + self.snaps: List[Dict[str, Any]] = [] + self.firewall_runtime: Dict[str, Any] = {} def has_resources(self) -> bool: - return super().has_resources() or bool(self.container_images) + return ( + super().has_resources() + or bool(self.container_images) + or bool(self.flatpak_remotes) + or bool(self.flatpaks) + or bool(self.snaps) + or bool(self.firewall_runtime) + ) @property def sls_name(self) -> str: @@ -87,10 +98,108 @@ class SaltRole(CMModule): user_data.update(_gecos_attrs(u.get("gecos"))) self.users[name] = user_data - if snap.get("user_flatpaks") or snap.get("user_flatpak_remotes"): - self.notes.append( - "Per-user Flatpak resources were detected but are not rendered as native Salt states." + home_by_user = { + str(u.get("name")): str(u.get("home") or "") + for u in (snap.get("users", []) or []) + if isinstance(u, dict) and u.get("name") + } + for remote in snap.get("user_flatpak_remotes", []) or []: + item = _normalise_flatpak_remote(remote) + user = str(item.get("user") or "").strip() + if user and not item.get("home"): + item["home"] = home_by_user.get(user) or f"/home/{user}" + if item.get("method") == "user" and item.get("name") and item.get("url"): + self.flatpak_remotes.append(_prepare_flatpak_remote(item)) + for uname, flatpaks in (snap.get("user_flatpaks", {}) or {}).items(): + user = str(uname) + for fp in flatpaks or []: + item = _normalise_flatpak_item( + fp, method="user", user=user, home=home_by_user.get(user) or None + ) + if item.get("name"): + self.flatpaks.append(_prepare_flatpak_item(item)) + + def add_flatpak_snapshot(self, snap: Dict[str, Any]) -> None: + for remote in snap.get("remotes", []) or []: + item = _normalise_flatpak_remote(remote) + if item.get("name") and item.get("url"): + self.flatpak_remotes.append(_prepare_flatpak_remote(item)) + for fp in snap.get("system_flatpaks", []) or []: + item = _normalise_flatpak_item(fp, method="system") + if item.get("name"): + self.flatpaks.append(_prepare_flatpak_item(item)) + for note in snap.get("notes", []) or []: + self.notes.append(str(note)) + + def add_snap_snapshot(self, snap: Dict[str, Any]) -> None: + for raw in snap.get("system_snaps", []) or []: + item = _normalise_snap_item(raw) + if item.get("name"): + self.snaps.append(_prepare_snap_item(item)) + for note in snap.get("notes", []) or []: + self.notes.append(str(note)) + + def add_firewall_runtime_snapshot( + self, + snap: Dict[str, Any], + *, + bundle_dir: str, + artifact_role: str, + role_files_dir: Path, + file_prefix: Optional[str] = None, + ) -> None: + self.packages.update( + str(p).strip() for p in (snap.get("packages") or []) if str(p).strip() + ) + self.add_managed_dir( + "/etc/enroll/firewall", + user="root", + group="root", + mode="0750", + require=[{"file": "/etc/enroll"}], + reason="firewall_runtime", + ) + runtime: Dict[str, Any] = {} + for key, dest_name, mode in ( + ("ipset_save", "ipset.save", "0600"), + ("iptables_v4_save", "iptables.v4", "0600"), + ("iptables_v6_save", "iptables.v6", "0600"), + ): + src_rel = str(snap.get(key) or "").strip() + if not src_rel: + continue + role_rel = _copy_artifact( + bundle_dir, + artifact_role, + src_rel, + role_files_dir, + dst_prefix=file_prefix, ) + if not role_rel: + self.notes.append( + f"Firewall runtime artifact {src_rel!r} was referenced but not found." + ) + continue + dest = f"/etc/enroll/firewall/{dest_name}" + self.add_managed_file( + dest, + user="root", + group="root", + mode=mode, + source=_source_uri(self.module_name, role_rel), + reason="firewall_runtime", + ) + runtime[key] = dest + ipset_sets = [ + str(x).strip() for x in (snap.get("ipset_sets") or []) if str(x).strip() + ] + if ipset_sets: + runtime["ipset_sets"] = ipset_sets + if runtime: + runtime.update(_firewall_runtime_commands(runtime)) + self.firewall_runtime.update(runtime) + for note in snap.get("notes", []) or []: + self.notes.append(str(note)) def add_container_images_snapshot(self, snap: Dict[str, Any]) -> None: for raw in snap.get("images", []) or []: @@ -304,6 +413,238 @@ def _container_tag_cmd(engine: str, pull_ref: str, tag_ref: str) -> str: return f"{engine} tag {_shell_quote(pull_ref)} {_shell_quote(tag_ref)}" +def _normalise_flatpak_item( + item: Dict[str, Any], + *, + method: str, + user: Optional[str] = None, + home: Optional[str] = None, +) -> Dict[str, Any]: + out = dict(item) + out["method"] = str(out.get("method") or method or "system").strip() or "system" + if user and not out.get("user"): + out["user"] = user + if home and not out.get("home"): + out["home"] = home + ref = str(out.get("ref") or "").strip() + if ref and not out.get("name"): + out["name"] = ref.rsplit("/", 1)[-1] + name = str(out.get("name") or out.get("app_id") or "").strip() + if name: + out["name"] = name + remote = str(out.get("remote") or "").strip() + if remote: + out["remote"] = remote + branch = str(out.get("branch") or out.get("origin") or "").strip() + if branch: + out["branch"] = branch + if ref: + out["ref"] = ref + return out + + +def _normalise_flatpak_remote(item: Dict[str, Any]) -> Dict[str, Any]: + out = dict(item) + name = str(out.get("name") or out.get("remote") or "").strip() + url = str(out.get("url") or out.get("from_url") or "").strip() + method = str(out.get("method") or out.get("scope") or "system").strip() or "system" + if name: + out["name"] = name + if url: + out["url"] = url + out["method"] = "user" if method == "user" else "system" + return out + + +def _normalise_snap_item(item: Dict[str, Any]) -> Dict[str, Any]: + out = dict(item) + name = str(out.get("name") or "").strip() + if name: + out["name"] = name + channel = str(out.get("tracking") or out.get("channel") or "").strip() + if channel: + out["channel"] = channel + notes = [str(note).lower() for note in (out.get("notes") or [])] + confinement = str(out.get("confinement") or "").strip().lower() + out["classic"] = confinement == "classic" or any( + "classic" in note for note in notes + ) + out["devmode"] = any("devmode" in note or "dev mode" in note for note in notes) + out["dangerous"] = any("dangerous" in note for note in notes) + revision = str(out.get("revision") or "").strip() + if revision and not channel: + out["revision"] = revision + return out + + +def _flatpak_scope(item: Dict[str, Any]) -> str: + return "--user" if str(item.get("method") or "system") == "user" else "--system" + + +def _flatpak_home(item: Dict[str, Any]) -> Optional[str]: + user = str(item.get("user") or "").strip() + if not user: + return None + return str(item.get("home") or f"/home/{user}") + + +def _flatpak_env(item: Dict[str, Any]) -> Dict[str, str]: + home = _flatpak_home(item) + if not home: + return {} + return {"HOME": home, "XDG_DATA_HOME": f"{home}/.local/share"} + + +def _flatpak_remote_exists_cmd(item: Dict[str, Any]) -> str: + return ( + f"flatpak {_flatpak_scope(item)} remote-list --columns=name " + f"| grep -Fx -- {_shell_quote(item.get('name'))}" + ) + + +def _flatpak_remote_add_cmd(item: Dict[str, Any]) -> str: + return ( + f"flatpak {_flatpak_scope(item)} remote-add --if-not-exists " + f"{_shell_quote(item.get('name'))} {_shell_quote(item.get('url'))}" + ) + + +def _flatpak_ref(item: Dict[str, Any]) -> str: + ref = str(item.get("ref") or "").strip() + if ref: + return ref + return str(item.get("name") or "").strip() + + +def _flatpak_exists_cmd(item: Dict[str, Any]) -> str: + return f"flatpak {_flatpak_scope(item)} info {_shell_quote(_flatpak_ref(item))} >/dev/null 2>&1" + + +def _flatpak_install_cmd(item: Dict[str, Any]) -> str: + args = ["flatpak", _flatpak_scope(item), "install", "-y"] + remote = str(item.get("remote") or "").strip() + if remote: + args.append(remote) + args.append(_flatpak_ref(item)) + return " ".join(_shell_quote(arg) for arg in args) + + +def _prepare_flatpak_remote(item: Dict[str, Any]) -> Dict[str, Any]: + out = dict(item) + method = str(out.get("method") or "system") + user = str(out.get("user") or "") + name = str(out.get("name") or "") + out["state_id"] = _state_id("flatpak_remote", f"{method}:{user}:{name}") + out["add_cmd"] = _flatpak_remote_add_cmd(out) + out["exists_cmd"] = _flatpak_remote_exists_cmd(out) + out["env"] = _flatpak_env(out) + return out + + +def _prepare_flatpak_item(item: Dict[str, Any]) -> Dict[str, Any]: + out = dict(item) + method = str(out.get("method") or "system") + user = str(out.get("user") or "") + ref = _flatpak_ref(out) + out["state_id"] = _state_id("flatpak", f"{method}:{user}:{ref}") + out["install_cmd"] = _flatpak_install_cmd(out) + out["exists_cmd"] = _flatpak_exists_cmd(out) + out["env"] = _flatpak_env(out) + return out + + +def _snap_exists_cmd(item: Dict[str, Any]) -> str: + return f"snap list {_shell_quote(item.get('name'))} >/dev/null 2>&1" + + +def _snap_install_cmd(item: Dict[str, Any]) -> str: + args = ["snap", "install", str(item.get("name") or "")] + channel = str(item.get("channel") or "").strip() + revision = str(item.get("revision") or "").strip() + if channel: + args.append(f"--channel={channel}") + elif revision: + args.append(f"--revision={revision}") + if item.get("classic"): + args.append("--classic") + if item.get("devmode"): + args.append("--devmode") + if item.get("dangerous"): + args.append("--dangerous") + return " ".join(_shell_quote(arg) for arg in args if str(arg)) + + +def _prepare_snap_item(item: Dict[str, Any]) -> Dict[str, Any]: + out = dict(item) + name = str(out.get("name") or "") + out["state_id"] = _state_id("snap", name) + out["install_cmd"] = _snap_install_cmd(out) + out["exists_cmd"] = _snap_exists_cmd(out) + return out + + +def _firewall_ipset_restore_cmd(path: str, sets: List[str]) -> str: + flush_parts = [f"ipset flush {_shell_quote(name)} || true" for name in sets] + flush = "; ".join(flush_parts) + restore = f"ipset restore -exist < {_shell_quote(path)}" + if flush: + return f"/bin/sh -c {_shell_quote(flush + '; ' + restore)}" + return f"/bin/sh -c {_shell_quote(restore)}" + + +def _firewall_runtime_commands(runtime: Dict[str, Any]) -> Dict[str, Any]: + out: Dict[str, Any] = {} + ipset_path = str(runtime.get("ipset_save") or "") + if ipset_path: + sets = [str(x) for x in (runtime.get("ipset_sets") or []) if str(x)] + out["ipset_restore_cmd"] = _firewall_ipset_restore_cmd(ipset_path, sets) + ipt4_path = str(runtime.get("iptables_v4_save") or "") + if ipt4_path: + out["iptables_v4_restore_cmd"] = f"iptables-restore {_shell_quote(ipt4_path)}" + ipt6_path = str(runtime.get("iptables_v6_save") or "") + if ipt6_path: + out["iptables_v6_restore_cmd"] = f"ip6tables-restore {_shell_quote(ipt6_path)}" + return out + + +def _append_firewall_runtime_states(lines: List[str], runtime: Dict[str, Any]) -> None: + specs = [ + ( + "ipset", + "ipset_save", + "ipset_restore_cmd", + "enroll_firewall_runtime_ipset_restore", + ), + ( + "iptables_v4", + "iptables_v4_save", + "iptables_v4_restore_cmd", + "enroll_firewall_runtime_iptables_v4_restore", + ), + ( + "iptables_v6", + "iptables_v6_save", + "iptables_v6_restore_cmd", + "enroll_firewall_runtime_iptables_v6_restore", + ), + ] + for _family, path_key, cmd_key, state_id in specs: + path = str(runtime.get(path_key) or "") + command = str(runtime.get(cmd_key) or "") + if not path or not command: + continue + lines.extend( + [ + f"{state_id}:", + " cmd.run:", + f" - name: {_yaml_quote(command)}", + " - onchanges:", + f" - file: {_yaml_quote(path)}", + "", + ] + ) + + def _clean_gecos_part(value: Any) -> Optional[str]: text = str(value or "").strip() return text or None @@ -402,23 +743,6 @@ def _node_sls_basename(fqdn: str) -> str: return f"{name}_{digest}" -def _add_flatpak_snap_notes(roles: Dict[str, Any], out: Dict[str, SaltRole]) -> None: - flatpak = roles.get("flatpak") or {} - if isinstance(flatpak, dict) and ( - flatpak.get("system_flatpaks") or flatpak.get("remotes") - ): - srole = out.setdefault("flatpak", SaltRole("flatpak")) - srole.notes.append( - "Flatpak resources were detected but are not rendered as native Salt states." - ) - snap = roles.get("snap") or {} - if isinstance(snap, dict) and snap.get("system_snaps"): - srole = out.setdefault("snap", SaltRole("snap")) - srole.notes.append( - "Snap resources were detected but are not rendered as native Salt states." - ) - - def _collect_salt_roles( state: Dict[str, Any], bundle_dir: str, @@ -563,15 +887,37 @@ def _collect_salt_roles( packages = [ str(p).strip() for p in (fw.get("packages") or []) if str(p).strip() ] - if has_fw or packages: - srole = ensure_role(str(fw.get("role_name") or "firewall_runtime")) - srole.packages.update(packages) + if has_fw or packages or fw.get("notes"): if has_fw: - srole.notes.append( - "Live firewall runtime snapshots were detected but are not rendered as Salt states." + runtime_role = ensure_role("enroll_runtime") + runtime_role.add_managed_dir( + "/etc/enroll", + user="root", + group="root", + mode="0750", + reason="enroll_runtime", ) + role_name = str(fw.get("role_name") or "firewall_runtime") + srole = ensure_role(role_name) + srole.add_firewall_runtime_snapshot( + fw, + bundle_dir=bundle_dir, + artifact_role=role_name, + role_files_dir=states_dir / "roles" / srole.module_name / "files", + file_prefix=node_file_prefix, + ) - _add_flatpak_snap_notes(roles, out) + flatpak = roles.get("flatpak") or {} + if isinstance(flatpak, dict) and ( + flatpak.get("system_flatpaks") or flatpak.get("remotes") or flatpak.get("notes") + ): + srole = ensure_role(str(flatpak.get("role_name") or "flatpak")) + srole.add_flatpak_snapshot(flatpak) + + snap = roles.get("snap") or {} + if isinstance(snap, dict) and (snap.get("system_snaps") or snap.get("notes")): + srole = ensure_role(str(snap.get("role_name") or "snap")) + srole.add_snap_snapshot(snap) salt_roles = sorted(out.values(), key=lambda r: role_order_key(r.role_name)) resolve_catalog_conflicts(salt_roles) @@ -653,9 +999,15 @@ 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 '0755'))}", " - makedirs: true", - "", ] ) + if attrs.get("require"): + lines.append(" - require:") + for req in attrs.get("require") or []: + if isinstance(req, dict): + for req_kind, req_name in req.items(): + lines.append(f" - {req_kind}: {_yaml_quote(req_name)}") + lines.append("") for path, attrs in sorted(srole.files.items()): lines.extend( @@ -700,6 +1052,101 @@ def _render_static_role(srole: SaltRole) -> str: ] ) + flatpak_remote_state_ids: Dict[Tuple[str, str, str], str] = {} + for remote in srole.flatpak_remotes: + name = str(remote.get("name") or "").strip() + url = str(remote.get("url") or "").strip() + if not name or not url: + continue + state_id = str( + remote.get("state_id") + or _state_id("flatpak_remote", name, role=srole.module_name) + ) + key = ( + str(remote.get("method") or "system"), + str(remote.get("user") or ""), + name, + ) + flatpak_remote_state_ids[key] = state_id + lines.extend( + [ + f"{state_id}:", + " cmd.run:", + f" - name: {_yaml_quote(remote.get('add_cmd') or _flatpak_remote_add_cmd(remote))}", + f" - unless: {_yaml_quote(remote.get('exists_cmd') or _flatpak_remote_exists_cmd(remote))}", + ] + ) + remote_user = str(remote.get("user") or "") + if remote_user: + lines.append(f" - runas: {_yaml_quote(remote_user)}") + env = remote.get("env") or {} + if env: + lines.append(" - env:") + for key_name, value in sorted(env.items()): + lines.append(f" - {key_name}: {_yaml_quote(value)}") + if remote_user and remote_user in srole.users: + lines.extend( + [ + " - require:", + f" - user: {_state_id('user', remote_user, role=srole.module_name)}", + ] + ) + lines.append("") + + for app in srole.flatpaks: + ref = _flatpak_ref(app) + if not ref: + continue + state_id = str( + app.get("state_id") or _state_id("flatpak", ref, role=srole.module_name) + ) + method = str(app.get("method") or "system") + user = str(app.get("user") or "") + remote_name = str(app.get("remote") or "") + require_entries: List[Tuple[str, str]] = [] + if user and user in srole.users: + require_entries.append( + ("user", _state_id("user", user, role=srole.module_name)) + ) + if remote_name: + remote_state_id = flatpak_remote_state_ids.get((method, user, remote_name)) + if remote_state_id: + require_entries.append(("cmd", remote_state_id)) + lines.extend( + [ + f"{state_id}:", + " cmd.run:", + f" - name: {_yaml_quote(app.get('install_cmd') or _flatpak_install_cmd(app))}", + f" - unless: {_yaml_quote(app.get('exists_cmd') or _flatpak_exists_cmd(app))}", + ] + ) + if app.get("user"): + lines.append(f" - runas: {_yaml_quote(app.get('user'))}") + env = app.get("env") or {} + if env: + lines.append(" - env:") + for key_name, value in sorted(env.items()): + lines.append(f" - {key_name}: {_yaml_quote(value)}") + if require_entries: + lines.append(" - require:") + for req_kind, req_name in require_entries: + lines.append(f" - {req_kind}: {req_name}") + lines.append("") + + for snap in srole.snaps: + name = str(snap.get("name") or "").strip() + if not name: + continue + lines.extend( + [ + f"{snap.get('state_id') or _state_id('snap', name, role=srole.module_name)}:", + " cmd.run:", + f" - name: {_yaml_quote(snap.get('install_cmd') or _snap_install_cmd(snap))}", + f" - unless: {_yaml_quote(snap.get('exists_cmd') or _snap_exists_cmd(snap))}", + "", + ] + ) + for idx, image in enumerate(srole.container_images, start=1): engine = str(image.get("engine") or "").strip() pull_ref = str(image.get("pull_ref") or "").strip() @@ -758,6 +1205,9 @@ def _render_static_role(srole: SaltRole) -> str: ] ) + if srole.firewall_runtime: + _append_firewall_runtime_states(lines, srole.firewall_runtime) + if "/etc/sysctl.d/99-enroll.conf" in srole.files: lines.extend( [ @@ -815,6 +1265,7 @@ def _role_pillar_values(srole: SaltRole) -> Dict[str, Any]: "group": attrs.get("group") or "root", "mode": str(attrs.get("mode") or "0755"), "makedirs": True, + **({"require": attrs.get("require")} if attrs.get("require") else {}), } for path, attrs in sorted(srole.dirs.items()) } @@ -853,8 +1304,16 @@ def _role_pillar_values(srole: SaltRole) -> Dict[str, Any]: } if "/etc/sysctl.d/99-enroll.conf" in srole.files: data["sysctl_apply"] = True + if srole.flatpak_remotes: + data["flatpak_remotes"] = list(srole.flatpak_remotes) + if srole.flatpaks: + data["flatpaks"] = list(srole.flatpaks) + if srole.snaps: + data["snaps"] = list(srole.snaps) if srole.container_images: data["container_images"] = list(srole.container_images) + if srole.firewall_runtime: + data["firewall_runtime"] = dict(srole.firewall_runtime) if srole.notes: data["notes"] = list(srole.notes) return data @@ -915,6 +1374,14 @@ def _render_pillar_role(srole: SaltRole) -> str: " - group: {{ attrs.get('group', 'root')|yaml_dquote }}", " - mode: {{ attrs.get('mode', '0755')|string|yaml_dquote }}", " - makedirs: {{ attrs.get('makedirs', True)|yaml_encode }}", + "{% if attrs.get('require') %}", + " - require:", + "{% for req in attrs.get('require', []) %}", + "{% for req_kind, req_name in req.items() %}", + " - {{ req_kind }}: {{ req_name|yaml_dquote }}", + "{% endfor %}", + "{% endfor %}", + "{% endif %}", "{% endfor %}", "", "{% for path, attrs in role.get('files', {}).items() %}", @@ -948,6 +1415,45 @@ def _render_pillar_role(srole: SaltRole) -> str: " - enable: {{ svc.get('enable', False)|yaml_encode }}", "{% endfor %}", "", + "{% for remote in role.get('flatpak_remotes', []) %}", + "{{ remote.get('state_id') }}:", + " cmd.run:", + " - name: {{ remote.get('add_cmd')|yaml_dquote }}", + " - unless: {{ remote.get('exists_cmd')|yaml_dquote }}", + "{% if remote.get('user') %}", + " - runas: {{ remote.get('user')|yaml_dquote }}", + "{% endif %}", + "{% if remote.get('env') %}", + " - env:", + "{% for env_key, env_value in remote.get('env', {}).items() %}", + " - {{ env_key }}: {{ env_value|yaml_dquote }}", + "{% endfor %}", + "{% endif %}", + "{% endfor %}", + "", + "{% for app in role.get('flatpaks', []) %}", + "{{ app.get('state_id') }}:", + " cmd.run:", + " - name: {{ app.get('install_cmd')|yaml_dquote }}", + " - unless: {{ app.get('exists_cmd')|yaml_dquote }}", + "{% if app.get('user') %}", + " - runas: {{ app.get('user')|yaml_dquote }}", + "{% endif %}", + "{% if app.get('env') %}", + " - env:", + "{% for env_key, env_value in app.get('env', {}).items() %}", + " - {{ env_key }}: {{ env_value|yaml_dquote }}", + "{% endfor %}", + "{% endif %}", + "{% endfor %}", + "", + "{% for snap in role.get('snaps', []) %}", + "{{ snap.get('state_id') }}:", + " cmd.run:", + " - name: {{ snap.get('install_cmd')|yaml_dquote }}", + " - unless: {{ snap.get('exists_cmd')|yaml_dquote }}", + "{% endfor %}", + "", "{% for image in role.get('container_images', []) %}", "{% if image.get('engine') == 'docker' and image.get('pull_ref') %}", f"enroll_docker_pull_{role_key}_{{{{ loop.index }}}}:", @@ -980,6 +1486,31 @@ def _render_pillar_role(srole: SaltRole) -> str: "{% endif %}", "{% endfor %}", "", + "{% set firewall_runtime = role.get('firewall_runtime', {}) %}", + "{% if firewall_runtime.get('ipset_restore_cmd') %}", + "enroll_firewall_runtime_ipset_restore:", + " cmd.run:", + " - name: {{ firewall_runtime.get('ipset_restore_cmd')|yaml_dquote }}", + " - onchanges:", + " - file: {{ firewall_runtime.get('ipset_save')|yaml_dquote }}", + "{% endif %}", + "", + "{% if firewall_runtime.get('iptables_v4_restore_cmd') %}", + "enroll_firewall_runtime_iptables_v4_restore:", + " cmd.run:", + " - name: {{ firewall_runtime.get('iptables_v4_restore_cmd')|yaml_dquote }}", + " - onchanges:", + " - file: {{ firewall_runtime.get('iptables_v4_save')|yaml_dquote }}", + "{% endif %}", + "", + "{% if firewall_runtime.get('iptables_v6_restore_cmd') %}", + "enroll_firewall_runtime_iptables_v6_restore:", + " cmd.run:", + " - name: {{ firewall_runtime.get('iptables_v6_restore_cmd')|yaml_dquote }}", + " - onchanges:", + " - file: {{ firewall_runtime.get('iptables_v6_save')|yaml_dquote }}", + "{% endif %}", + "", "{% if role.get('sysctl_apply') and '/etc/sysctl.d/99-enroll.conf' in role.get('files', {}) %}", f"enroll_apply_sysctl_{role_key}:", " cmd.run:", @@ -1164,10 +1695,12 @@ This Salt target reuses the existing harvest state without changing harvesting b - `/etc/sysctl.d/99-enroll.conf` plus an `onchanges` sysctl apply command when present. - Docker images by digest using guarded `docker pull` / `docker tag` command states. - Podman images by digest using guarded `podman pull` / `podman tag` command states. +- Flatpak remotes and applications using guarded `flatpak remote-add` / `flatpak install` command states. +- Snap applications using guarded `snap install` command states. +- Live firewall runtime snapshots using staged `/etc/enroll/firewall/*` files and guarded restore command states. ## Current limitations -- Flatpak, Snap, and live firewall runtime snapshots are listed as notes when present rather than rendered as Salt states. - 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. diff --git a/pytests.sh b/pytests.sh index d49d04b..30bddb5 100755 --- a/pytests.sh +++ b/pytests.sh @@ -2,4 +2,4 @@ set -eou pipefail -poetry run pytest -q tests -vvv --cov=enroll +poetry run pytest -q tests -vvv --cov=enroll --cov-report=term-missing diff --git a/tests/test_jinjaturtle.py b/tests/test_jinjaturtle.py index 2126fce..e4198c7 100644 --- a/tests/test_jinjaturtle.py +++ b/tests/test_jinjaturtle.py @@ -147,3 +147,87 @@ def test_openssh_paths_are_jinjaturtle_supported_and_forced_to_ssh() -> None: assert can_jinjify_path("/etc/ssh/sshd_config") assert can_jinjify_path("/etc/ssh/ssh_config") + + +def test_jinjify_managed_files_namespaces_multiple_templates( + monkeypatch, tmp_path: Path +): + from enroll.jinjaturtle import jinjify_managed_files + + bundle = tmp_path / "bundle" + template_root = tmp_path / "templates" + for rel in ("etc/foo/a.yaml", "etc/foo/b.yaml"): + path = bundle / "artifacts" / "foo" / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("ignore: []\n", encoding="utf-8") + + calls = [] + + def fake_run_jinjaturtle(jt_exe, src_path, *, role_name, force_format=None): + calls.append((Path(src_path).name, role_name)) + return JinjifyResult( + template_text=f"ignore: {{{{ {role_name}_ignore }}}}\n", + vars_text=f"{role_name}_ignore: []\n", + ) + + monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle) + + templated, vars_text = jinjify_managed_files( + bundle, + "foo", + template_root, + [ + {"path": "/etc/foo/a.yaml", "src_rel": "etc/foo/a.yaml"}, + {"path": "/etc/foo/b.yaml", "src_rel": "etc/foo/b.yaml"}, + ], + jt_exe="jinjaturtle", + jt_enabled=True, + overwrite_templates=True, + role_name="foo", + ) + + assert templated == {"etc/foo/a.yaml", "etc/foo/b.yaml"} + assert calls == [ + ("a.yaml", "foo_etc_foo_a_yaml"), + ("b.yaml", "foo_etc_foo_b_yaml"), + ] + assert "foo_etc_foo_a_yaml_ignore: []" in vars_text + assert "foo_etc_foo_b_yaml_ignore: []" in vars_text + assert (template_root / "etc" / "foo" / "a.yaml.j2").read_text( + encoding="utf-8" + ) == "ignore: {{ foo_etc_foo_a_yaml_ignore }}\n" + + +def test_jinjify_managed_files_rejects_templates_with_missing_defaults( + monkeypatch, tmp_path: Path +): + from enroll.jinjaturtle import jinjify_managed_files + + bundle = tmp_path / "bundle" + template_root = tmp_path / "templates" + artifact = bundle / "artifacts" / "foo" / "etc" / "foo" / "pdk.yaml" + artifact.parent.mkdir(parents=True, exist_ok=True) + artifact.write_text("ignore: []\n", encoding="utf-8") + + def fake_run_jinjaturtle(jt_exe, src_path, *, role_name, force_format=None): + return JinjifyResult( + template_text=f"ignore: {{{{ {role_name}_ignore }}}}\n", + vars_text="--- {}\n", + ) + + monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle) + + templated, vars_text = jinjify_managed_files( + bundle, + "foo", + template_root, + [{"path": "/etc/foo/pdk.yaml", "src_rel": "etc/foo/pdk.yaml"}], + jt_exe="jinjaturtle", + jt_enabled=True, + overwrite_templates=True, + role_name="foo", + ) + + assert templated == set() + assert vars_text == "" + assert not (template_root / "etc" / "foo" / "pdk.yaml.j2").exists() diff --git a/tests/test_manifest.py b/tests/test_manifest.py index c56566c..9c10df7 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -1308,9 +1308,14 @@ def test_manifest_writes_firewall_runtime_role(tmp_path: Path): tasks = (out / "roles" / "firewall_runtime" / "tasks" / "main.yml").read_text( encoding="utf-8" ) - assert "ipset restore -exist" in tasks - assert "iptables-restore /etc/enroll/firewall/iptables.v4" in tasks - assert "ipset flush {{ item }}" in tasks + handlers = (out / "roles" / "firewall_runtime" / "handlers" / "main.yml").read_text( + encoding="utf-8" + ) + assert "notify: Restore captured ipsets" in tasks + assert "notify: Restore captured IPv4 iptables rules" in tasks + assert "ipset restore -exist" in handlers + assert "iptables-restore /etc/enroll/firewall/iptables.v4" in handlers + assert "ipset flush {{ item }}" in handlers defaults = (out / "roles" / "firewall_runtime" / "defaults" / "main.yml").read_text( encoding="utf-8" @@ -1320,7 +1325,13 @@ def test_manifest_writes_firewall_runtime_role(tmp_path: Path): assert "firewall_runtime_restore_iptables: true" in defaults pb = (out / "playbook.yml").read_text(encoding="utf-8") + assert "role: enroll_runtime" in pb assert "role: firewall_runtime" in pb + assert pb.index("role: enroll_runtime") < pb.index("role: firewall_runtime") + runtime_tasks = (out / "roles" / "enroll_runtime" / "tasks" / "main.yml").read_text( + encoding="utf-8" + ) + assert "path: /etc/enroll" in runtime_tasks assert ( out / "roles" / "firewall_runtime" / "files" / "firewall" / "ipset.save" ).exists() @@ -2076,6 +2087,8 @@ def test_manifest_renders_container_image_role_for_ansible(tmp_path: Path): assert podman_digest in defaults assert "community.docker.docker_image_pull" in tasks assert "community.docker.docker_image_tag" in tasks + assert "selectattr('pull_ref')" in tasks + assert "item.pull_ref | default('', true) | length > 0" in tasks assert "containers.podman.podman_image" in tasks assert "containers.podman.podman_tag" in tasks assert "repository:" in tasks diff --git a/tests/test_manifest_puppet.py b/tests/test_manifest_puppet.py index 1fd4b1b..fd2fa7e 100644 --- a/tests/test_manifest_puppet.py +++ b/tests/test_manifest_puppet.py @@ -712,3 +712,89 @@ def test_manifest_puppet_renders_container_images_static_and_hiera(tmp_path: Pat assert "podman pull" in ( fqdn_out / "data" / "nodes" / "node.example.yaml" ).read_text(encoding="utf-8") + + +def test_manifest_puppet_renders_firewall_runtime_resources(tmp_path: Path): + bundle = tmp_path / "bundle" + out = tmp_path / "puppet" + fw_dir = bundle / "artifacts" / "firewall_runtime" / "firewall" + fw_dir.mkdir(parents=True, exist_ok=True) + (fw_dir / "ipset.save").write_text( + "create blocklist hash:ip family inet\nadd blocklist 203.0.113.10\n", + encoding="utf-8", + ) + (fw_dir / "iptables.v4").write_text( + "*filter\n:INPUT DROP [0:0]\n-A INPUT -m set --match-set blocklist src -j DROP\nCOMMIT\n", + encoding="utf-8", + ) + state = { + "schema_version": 3, + "host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"}, + "inventory": {"packages": {}}, + "roles": { + "firewall_runtime": { + "role_name": "firewall_runtime", + "packages": ["ipset", "iptables"], + "ipset_save": "firewall/ipset.save", + "ipset_sets": ["blocklist"], + "iptables_v4_save": "firewall/iptables.v4", + "iptables_v6_save": None, + "notes": [], + } + }, + } + _write_state(bundle, state) + + manifest.manifest(str(bundle), str(out), target="puppet") + + pp = (out / "modules" / "firewall_runtime" / "manifests" / "init.pp").read_text( + encoding="utf-8" + ) + runtime_pp = ( + out / "modules" / "enroll_runtime" / "manifests" / "init.pp" + ).read_text(encoding="utf-8") + assert "file { '/etc/enroll':" in runtime_pp + assert "file { '/etc/enroll':" not in pp + assert "file { '/etc/enroll/firewall':" in pp + assert "require => File['/etc/enroll']," in pp + assert "file { '/etc/enroll/firewall/ipset.save':" in pp + assert "ipset restore -exist" in pp + assert "ipset flush blocklist" in pp + assert "iptables-restore /etc/enroll/firewall/iptables.v4" in pp + assert "refreshonly => true" in pp + assert "subscribe => File['/etc/enroll/firewall/iptables.v4']" in pp + assert "iptables-save >" not in pp + assert "Live firewall runtime snapshots were detected" not in pp + assert ( + out / "modules" / "firewall_runtime" / "files" / "firewall" / "ipset.save" + ).exists() + + fqdn_out = tmp_path / "puppet-fqdn" + manifest.manifest(str(bundle), str(fqdn_out), target="puppet", fqdn="node.example") + node_data = yaml.safe_load( + (fqdn_out / "data" / "nodes" / "node.example.yaml").read_text(encoding="utf-8") + ) + assert "enroll_runtime" in node_data["enroll::classes"] + assert "firewall_runtime" in node_data["enroll::classes"] + assert node_data["enroll::classes"].index("enroll_runtime") < node_data[ + "enroll::classes" + ].index("firewall_runtime") + assert node_data["enroll_runtime::dirs"]["/etc/enroll"]["ensure"] == "directory" + assert node_data["firewall_runtime::firewall_runtime"]["ipset_sets"] == [ + "blocklist" + ] + assert ( + "ipset restore -exist" + in node_data["firewall_runtime::firewall_runtime"]["ipset_restore_cmd"] + ) + assert ( + node_data["firewall_runtime::files"]["/etc/enroll/firewall/ipset.save"][ + "source" + ] + == "puppet:///modules/firewall_runtime/nodes/node.example/firewall/ipset.save" + ) + fqdn_pp = ( + fqdn_out / "modules" / "firewall_runtime" / "manifests" / "init.pp" + ).read_text(encoding="utf-8") + assert "Hash $firewall_runtime = {}" in fqdn_pp + assert "$firewall_runtime['ipset_restore_cmd']" in fqdn_pp diff --git a/tests/test_manifest_salt.py b/tests/test_manifest_salt.py index c30d509..1fa6804 100644 --- a/tests/test_manifest_salt.py +++ b/tests/test_manifest_salt.py @@ -532,3 +532,95 @@ def test_manifest_salt_uses_jinjaturtle_templates(monkeypatch, tmp_path: Path): assert file_data["source"] == "salt://roles/foo/templates/etc/foo.conf.j2" assert file_data["template"] == "jinja" assert file_data["context"] == {"foo_setting": True} + + +def test_manifest_salt_renders_firewall_runtime_states(tmp_path: Path): + bundle = tmp_path / "bundle" + out = tmp_path / "salt" + fw_dir = bundle / "artifacts" / "firewall_runtime" / "firewall" + fw_dir.mkdir(parents=True, exist_ok=True) + (fw_dir / "ipset.save").write_text( + "create blocklist hash:ip family inet\nadd blocklist 203.0.113.10\n", + encoding="utf-8", + ) + (fw_dir / "iptables.v4").write_text( + "*filter\n:INPUT DROP [0:0]\n-A INPUT -m set --match-set blocklist src -j DROP\nCOMMIT\n", + encoding="utf-8", + ) + state = { + "schema_version": 3, + "host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"}, + "inventory": {"packages": {}}, + "roles": { + "firewall_runtime": { + "role_name": "firewall_runtime", + "packages": ["ipset", "iptables"], + "ipset_save": "firewall/ipset.save", + "ipset_sets": ["blocklist"], + "iptables_v4_save": "firewall/iptables.v4", + "iptables_v6_save": None, + "notes": [], + } + }, + } + _write_state(bundle, state) + + manifest.manifest(str(bundle), str(out), target="salt") + + top = yaml.safe_load((out / "states" / "top.sls").read_text(encoding="utf-8")) + assert "roles.enroll_runtime" in top["base"]["*"] + assert top["base"]["*"].index("roles.enroll_runtime") < top["base"]["*"].index( + "roles.firewall_runtime" + ) + runtime_sls = (out / "states" / "roles" / "enroll_runtime" / "init.sls").read_text( + encoding="utf-8" + ) + assert '"/etc/enroll":' in runtime_sls + sls = (out / "states" / "roles" / "firewall_runtime" / "init.sls").read_text( + encoding="utf-8" + ) + assert '"/etc/enroll":' not in sls + assert '"/etc/enroll/firewall":' in sls + assert '- file: "/etc/enroll"' in sls + assert '"/etc/enroll/firewall/ipset.save":' in sls + assert "ipset restore -exist" in sls + assert "ipset flush blocklist" in sls + assert "iptables-restore /etc/enroll/firewall/iptables.v4" in sls + assert " - onchanges:" in sls + assert ' - file: "/etc/enroll/firewall/iptables.v4"' in sls + assert "iptables-save >" not in sls + assert "Live firewall runtime snapshots were detected" not in sls + assert ( + out + / "states" + / "roles" + / "firewall_runtime" + / "files" + / "firewall" + / "ipset.save" + ).exists() + + fqdn_out = tmp_path / "salt-fqdn" + manifest.manifest(str(bundle), str(fqdn_out), target="salt", fqdn="node.example") + 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")) + assert "roles.enroll_runtime" in pillar["enroll"]["classes"] + assert "firewall_runtime" in pillar["enroll"]["roles"] + assert ( + pillar["enroll"]["roles"]["enroll_runtime"]["dirs"]["/etc/enroll"]["mode"] + == "0750" + ) + role_data = pillar["enroll"]["roles"]["firewall_runtime"] + assert role_data["firewall_runtime"]["ipset_sets"] == ["blocklist"] + assert "ipset restore -exist" in role_data["firewall_runtime"]["ipset_restore_cmd"] + assert role_data["files"]["/etc/enroll/firewall/ipset.save"]["source"] == ( + "salt://roles/firewall_runtime/files/nodes/node.example/firewall/ipset.save" + ) + fqdn_sls = ( + fqdn_out / "states" / "roles" / "firewall_runtime" / "init.sls" + ).read_text(encoding="utf-8") + assert "firewall_runtime.get('ipset_restore_cmd')" in fqdn_sls