loooots of fixes.
Some checks failed
CI / test (push) Failing after 20m26s
Lint / test (push) Successful in 44s

This commit is contained in:
Miguel Jacq 2026-06-19 18:55:30 +10:00
parent b8926f9a5f
commit de42e16510
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
12 changed files with 1579 additions and 116 deletions

2
.gitignore vendored
View file

@ -9,3 +9,5 @@ dist
*.csv *.csv
*.html *.html
coverage.xml coverage.xml
*.orig
*.rej

View file

@ -472,7 +472,7 @@ Or with absolute paths:
sudo puppet apply --modulepath /tmp/enroll-puppet/modules /tmp/enroll-puppet/manifests/site.pp --noop 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 ### Salt target
```bash ```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`). 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` ### Manifest with `--sops`
```bash ```bash

View file

@ -747,25 +747,7 @@ def _render_firewall_runtime_tasks(var_prefix: str) -> str:
owner: root owner: root
group: root group: root
mode: "0600" mode: "0600"
when: ({var_prefix}_ipset_save | default('') | length) > 0 notify: Restore captured ipsets
- 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
when: ({var_prefix}_ipset_save | default('') | length) > 0 when: ({var_prefix}_ipset_save | default('') | length) > 0
- name: Deploy captured IPv4 iptables snapshot - name: Deploy captured IPv4 iptables snapshot
@ -780,17 +762,9 @@ def _render_firewall_runtime_tasks(var_prefix: str) -> str:
owner: root owner: root
group: root group: root
mode: "0600" mode: "0600"
notify: Restore captured IPv4 iptables rules
when: ({var_prefix}_iptables_v4_save | default('') | length) > 0 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 - name: Deploy captured IPv6 iptables snapshot
vars: vars:
_enroll_ff: _enroll_ff:
@ -803,13 +777,50 @@ def _render_firewall_runtime_tasks(var_prefix: str) -> str:
owner: root owner: root
group: root group: root
mode: "0600" mode: "0600"
notify: Restore captured IPv6 iptables rules
when: ({var_prefix}_iptables_v6_save | default('') | length) > 0 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 - name: Restore captured IPv6 iptables rules
ansible.builtin.command: ansible.builtin.command:
cmd: ip6tables-restore /etc/enroll/firewall/iptables.v6 cmd: ip6tables-restore /etc/enroll/firewall/iptables.v6
register: _enroll_iptables_v6_restore listen: Restore captured IPv6 iptables rules
changed_when: _enroll_iptables_v6_restore.rc == 0
when: when:
- ({var_prefix}_iptables_v6_save | default('') | length) > 0 - ({var_prefix}_iptables_v6_save | default('') | length) > 0
- {var_prefix}_restore_iptables | default(true) | bool - {var_prefix}_restore_iptables | default(true) | bool
@ -1236,9 +1247,9 @@ def _render_container_images_role(
name: "{{ item.pull_ref }}" name: "{{ item.pull_ref }}"
pull: not_present pull: not_present
platform: "{{ item.platform | default(omit, true) }}" 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: when:
- item.pull_ref | default('') | length > 0 - item.pull_ref | default('', true) | length > 0
become: true become: true
- name: Tag Docker images with harvested tag aliases - name: Tag Docker images with harvested tag aliases
@ -1246,11 +1257,11 @@ def _render_container_images_role(
name: "{{ item.0.pull_ref }}" name: "{{ item.0.pull_ref }}"
repository: repository:
- "{{ item.1.ref }}" - "{{ 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: when:
- item.0.pull_ref | default('') | length > 0 - item.0.pull_ref | default('', true) | length > 0
- item.1.repository | default('') | length > 0 - item.1.repository | default('', true) | length > 0
- item.1.tag | default('') | length > 0 - item.1.tag | default('', true) | length > 0
become: true become: true
- name: Pull system Podman images by immutable registry digest - name: Pull system Podman images by immutable registry digest
@ -1259,9 +1270,9 @@ def _render_container_images_role(
state: present state: present
force: false force: false
platform: "{{ item.platform | default(omit, true) }}" 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: when:
- item.pull_ref | default('') | length > 0 - item.pull_ref | default('', true) | length > 0
become: true become: true
- name: Tag system Podman images with harvested tag aliases - name: Tag system Podman images with harvested tag aliases
@ -1269,10 +1280,10 @@ def _render_container_images_role(
image: "{{ item.0.pull_ref }}" image: "{{ item.0.pull_ref }}"
target_names: target_names:
- "{{ item.1.ref }}" - "{{ 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: when:
- item.0.pull_ref | default('') | length > 0 - item.0.pull_ref | default('', true) | length > 0
- item.1.ref | default('') | length > 0 - item.1.ref | default('', true) | length > 0
become: true become: true
- name: Pull user Podman images by immutable registry digest - name: Pull user Podman images by immutable registry digest
@ -1281,10 +1292,10 @@ def _render_container_images_role(
state: present state: present
force: false force: false
platform: "{{ item.platform | default(omit, true) }}" 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: when:
- item.pull_ref | default('') | length > 0 - item.pull_ref | default('', true) | length > 0
- item.user | default('') | length > 0 - item.user | default('', true) | length > 0
become: true become: true
become_user: "{{ item.user }}" become_user: "{{ item.user }}"
@ -1293,11 +1304,11 @@ def _render_container_images_role(
image: "{{ item.0.pull_ref }}" image: "{{ item.0.pull_ref }}"
target_names: target_names:
- "{{ item.1.ref }}" - "{{ 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: when:
- item.0.pull_ref | default('') | length > 0 - item.0.pull_ref | default('', true) | length > 0
- item.0.user | default('') | length > 0 - item.0.user | default('', true) | length > 0
- item.1.ref | default('') | length > 0 - item.1.ref | default('', true) | length > 0
become: true become: true
become_user: "{{ item.0.user }}" become_user: "{{ item.0.user }}"
""" """
@ -2539,6 +2550,31 @@ Captured parameter count: {param_count}
return role 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( def _render_firewall_runtime_role(
ctx: AnsibleManifestContext, ctx: AnsibleManifestContext,
firewall_runtime_snapshot: Dict[str, Any], 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: with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f:
f.write(tasks.rstrip() + "\n") 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: with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f:
f.write("---\ndependencies: []\n") f.write("---\ndependencies: []\n")
@ -3091,6 +3132,9 @@ class AnsibleManifestRenderer:
firewall_role = _render_firewall_runtime_role( firewall_role = _render_firewall_runtime_role(
ctx, roles.get("firewall_runtime", {}) 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) service_roles = _render_service_roles(ctx, services_to_manifest)
occupied_role_names = set(managed_roles.values()) occupied_role_names = set(managed_roles.values())
@ -3101,6 +3145,7 @@ class AnsibleManifestRenderer:
container_role, container_role,
sysctl_role, sysctl_role,
firewall_role, firewall_role,
enroll_runtime_role,
): ):
if role: if role:
occupied_role_names.add(role) occupied_role_names.add(role)
@ -3127,7 +3172,7 @@ class AnsibleManifestRenderer:
ordered_roles = _ordered_playbook_roles( ordered_roles = _ordered_playbook_roles(
rendered_roles, ["cron", "logrotate"] + common_tail_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) _add_role(ordered_roles, role)
_write_manifest_playbook(ctx, ordered_roles) _write_manifest_playbook(ctx, ordered_roles)

View file

@ -197,6 +197,7 @@ def role_order_key(role: str) -> tuple[int, str]:
"extra_paths": 82, "extra_paths": 82,
"container_images": 88, "container_images": 88,
"users": 90, "users": 90,
"enroll_runtime": 94,
"sysctl": 95, "sysctl": 95,
"firewall_runtime": 99, "firewall_runtime": 99,
} }

View file

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import re
import shutil import shutil
import subprocess # nosec import subprocess # nosec
import tempfile import tempfile
@ -74,6 +76,49 @@ class JinjifiedArtifact:
context: Dict[str, Any] 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( def jinjify_artifact(
bundle_dir: str | Path, bundle_dir: str | Path,
artifact_role: str, artifact_role: str,
@ -113,6 +158,15 @@ def jinjify_artifact(
template_rel = Path(src_rel).as_posix() + ".j2" template_rel = Path(src_rel).as_posix() + ".j2"
template_dst = Path(template_root) / template_rel 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(): if overwrite_templates or not template_dst.exists():
template_dst.parent.mkdir(parents=True, exist_ok=True) template_dst.parent.mkdir(parents=True, exist_ok=True)
template_dst.write_text(result.template_text, encoding="utf-8") template_dst.write_text(result.template_text, encoding="utf-8")
@ -121,10 +175,32 @@ def jinjify_artifact(
template_rel=template_rel, template_rel=template_rel,
template_text=result.template_text, template_text=result.template_text,
vars_text=result.vars_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( def jinjify_managed_files(
bundle_dir: str | Path, bundle_dir: str | Path,
artifact_role: str, artifact_role: str,
@ -145,6 +221,15 @@ def jinjify_managed_files(
""" """
templated: Set[str] = set() templated: Set[str] = set()
vars_map: Dict[str, Any] = {} 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: for mf in managed_files:
dest_path = str(mf.get("path") or "") dest_path = str(mf.get("path") or "")
@ -161,7 +246,11 @@ def jinjify_managed_files(
jt_exe=jt_exe, jt_exe=jt_exe,
jt_enabled=jt_enabled, jt_enabled=jt_enabled,
overwrite_templates=overwrite_templates, 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: if converted is None:
continue continue

View file

@ -29,9 +29,20 @@ class PuppetRole(CMModule):
module_name=_puppet_name(role_name, fallback="enroll_role"), module_name=_puppet_name(role_name, fallback="enroll_role"),
) )
self.container_images: List[Dict[str, Any]] = [] 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: 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: def add_package_snapshot(self, snap: Dict[str, Any]) -> None:
pkg = str(snap.get("package") or "").strip() pkg = str(snap.get("package") or "").strip()
@ -83,10 +94,108 @@ class PuppetRole(CMModule):
"supplementary_groups": supplementary, "supplementary_groups": supplementary,
} }
if snap.get("user_flatpaks") or snap.get("user_flatpak_remotes"): home_by_user = {
self.notes.append( str(u.get("name")): str(u.get("home") or "")
"Per-user Flatpak resources were detected but are not yet rendered as native Puppet resources." 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: def add_container_images_snapshot(self, snap: Dict[str, Any]) -> None:
for raw in snap.get("images", []) or []: 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)}" 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: def _pp_array(values: Iterable[Any]) -> str:
return "[" + ", ".join(_pp_quote(v) for v in values) + "]" 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( def _resource(
lines: List[str], rtype: str, title: str, attrs: List[Tuple[str, str]] lines: List[str], rtype: str, title: str, attrs: List[Tuple[str, str]]
) -> None: ) -> None:
@ -293,6 +596,71 @@ def _state_title(prefix: str, value: Any) -> str:
return f"enroll-{prefix}-{safe}" 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( def _copy_artifact(
bundle_dir: str, bundle_dir: str,
role: 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}" 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: def _node_data_filename(fqdn: str) -> str:
"""Return a safe Hiera node-data filename for an FQDN/certname.""" """Return a safe Hiera node-data filename for an FQDN/certname."""
@ -480,15 +831,37 @@ def _collect_puppet_roles(
packages = [ packages = [
str(p).strip() for p in (fw.get("packages") or []) if str(p).strip() str(p).strip() for p in (fw.get("packages") or []) if str(p).strip()
] ]
if has_fw or packages: if has_fw or packages or fw.get("notes"):
prole = ensure_role(str(fw.get("role_name") or "firewall_runtime"))
prole.packages.update(packages)
if has_fw: if has_fw:
prole.notes.append( runtime_role = ensure_role("enroll_runtime")
"Live firewall runtime snapshots were detected but are not yet rendered as Puppet resources." 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)) puppet_roles = sorted(out.values(), key=lambda r: role_order_key(r.role_name))
resolve_catalog_conflicts(puppet_roles) resolve_catalog_conflicts(puppet_roles)
@ -549,6 +922,7 @@ def _render_role_class(prole: PuppetRole) -> str:
("owner", _pp_quote(d.get("owner") or "root")), ("owner", _pp_quote(d.get("owner") or "root")),
("group", _pp_quote(d.get("group") or "root")), ("group", _pp_quote(d.get("group") or "root")),
("mode", _pp_quote(d.get("mode") or "0755")), ("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: for image in prole.container_images:
engine = str(image.get("engine") or "").strip() engine = str(image.get("engine") or "").strip()
pull_ref = str(image.get("pull_ref") 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: if has_sysctl_conf:
lines.append(" if $sysctl_apply {") lines.append(" if $sysctl_apply {")
lines.append(" exec { 'enroll-apply-sysctl':") lines.append(" exec { 'enroll-apply-sysctl':")
@ -776,7 +1229,7 @@ def _role_hiera_values(prole: PuppetRole) -> Dict[str, Any]:
path: _attrs_with_ensure( path: _attrs_with_ensure(
prole.dirs[path], prole.dirs[path],
"directory", "directory",
allowed={"owner", "group", "mode"}, allowed={"owner", "group", "mode", "require"},
) )
for path in sorted(prole.dirs) 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) 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: if prole.container_images:
data[f"{prefix}container_images"] = list(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: if prole.notes:
data[f"{prefix}notes"] = list(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] $files = {},",
" Hash[String, Hash] $links = {},", " Hash[String, Hash] $links = {},",
" Hash[String, Hash] $services = {},", " Hash[String, Hash] $services = {},",
" Array[Hash] $flatpak_remotes = [],",
" Array[Hash] $flatpaks = [],",
" Array[Hash] $snaps = [],",
" Array[Hash] $container_images = [],", " Array[Hash] $container_images = [],",
" Hash $firewall_runtime = {},",
" Array[String] $notes = [],", " Array[String] $notes = [],",
" Boolean $sysctl_apply = true,", " Boolean $sysctl_apply = true,",
" Boolean $sysctl_ignore_apply_errors = 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| {", " $container_images.each |Integer $idx, Hash $image| {",
" if $image['engine'] == 'docker' and $image['pull_ref'] {", " if $image['engine'] == 'docker' and $image['pull_ref'] {",
' exec { "enroll-docker-pull-${idx}":', ' 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 {", " if $sysctl_apply and '/etc/sysctl.d/99-enroll.conf' in $files {",
" exec { 'enroll-apply-sysctl':", " exec { 'enroll-apply-sysctl':",
" command => $sysctl_ignore_apply_errors ? {", " command => $sysctl_ignore_apply_errors ? {",
@ -1145,7 +1665,6 @@ This Puppet target reuses the existing harvest state without changing harvesting
## Current limitations ## 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. - JinjaTurtle templating is currently Ansible-oriented and is not applied to Puppet output.
- Review generated resources before applying them broadly across unlike hosts. - Review generated resources before applying them broadly across unlike hosts.

View file

@ -31,9 +31,20 @@ class SaltRole(CMModule):
module_name=_salt_name(role_name, fallback="enroll_role"), module_name=_salt_name(role_name, fallback="enroll_role"),
) )
self.container_images: List[Dict[str, Any]] = [] 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: 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 @property
def sls_name(self) -> str: def sls_name(self) -> str:
@ -87,10 +98,108 @@ class SaltRole(CMModule):
user_data.update(_gecos_attrs(u.get("gecos"))) user_data.update(_gecos_attrs(u.get("gecos")))
self.users[name] = user_data self.users[name] = user_data
if snap.get("user_flatpaks") or snap.get("user_flatpak_remotes"): home_by_user = {
self.notes.append( str(u.get("name")): str(u.get("home") or "")
"Per-user Flatpak resources were detected but are not rendered as native Salt states." 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: def add_container_images_snapshot(self, snap: Dict[str, Any]) -> None:
for raw in snap.get("images", []) or []: 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)}" 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]: def _clean_gecos_part(value: Any) -> Optional[str]:
text = str(value or "").strip() text = str(value or "").strip()
return text or None return text or None
@ -402,23 +743,6 @@ def _node_sls_basename(fqdn: str) -> str:
return f"{name}_{digest}" 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( def _collect_salt_roles(
state: Dict[str, Any], state: Dict[str, Any],
bundle_dir: str, bundle_dir: str,
@ -563,15 +887,37 @@ def _collect_salt_roles(
packages = [ packages = [
str(p).strip() for p in (fw.get("packages") or []) if str(p).strip() str(p).strip() for p in (fw.get("packages") or []) if str(p).strip()
] ]
if has_fw or packages: if has_fw or packages or fw.get("notes"):
srole = ensure_role(str(fw.get("role_name") or "firewall_runtime"))
srole.packages.update(packages)
if has_fw: if has_fw:
srole.notes.append( runtime_role = ensure_role("enroll_runtime")
"Live firewall runtime snapshots were detected but are not rendered as Salt states." 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)) salt_roles = sorted(out.values(), key=lambda r: role_order_key(r.role_name))
resolve_catalog_conflicts(salt_roles) 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" - group: {_yaml_quote(attrs.get('group') or 'root')}",
f" - mode: {_yaml_quote(str(attrs.get('mode') or '0755'))}", f" - mode: {_yaml_quote(str(attrs.get('mode') or '0755'))}",
" - makedirs: true", " - 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()): for path, attrs in sorted(srole.files.items()):
lines.extend( 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): for idx, image in enumerate(srole.container_images, start=1):
engine = str(image.get("engine") or "").strip() engine = str(image.get("engine") or "").strip()
pull_ref = str(image.get("pull_ref") 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: if "/etc/sysctl.d/99-enroll.conf" in srole.files:
lines.extend( lines.extend(
[ [
@ -815,6 +1265,7 @@ 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 "0755"), "mode": str(attrs.get("mode") or "0755"),
"makedirs": True, "makedirs": True,
**({"require": attrs.get("require")} if attrs.get("require") else {}),
} }
for path, attrs in sorted(srole.dirs.items()) 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: if "/etc/sysctl.d/99-enroll.conf" in srole.files:
data["sysctl_apply"] = True 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: if srole.container_images:
data["container_images"] = list(srole.container_images) data["container_images"] = list(srole.container_images)
if srole.firewall_runtime:
data["firewall_runtime"] = dict(srole.firewall_runtime)
if srole.notes: if srole.notes:
data["notes"] = list(srole.notes) data["notes"] = list(srole.notes)
return data return data
@ -915,6 +1374,14 @@ 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', '0755')|string|yaml_dquote }}", " - mode: {{ attrs.get('mode', '0755')|string|yaml_dquote }}",
" - makedirs: {{ attrs.get('makedirs', True)|yaml_encode }}", " - 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 %}", "{% endfor %}",
"", "",
"{% for path, attrs in role.get('files', {}).items() %}", "{% 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 }}", " - enable: {{ svc.get('enable', False)|yaml_encode }}",
"{% endfor %}", "{% 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', []) %}", "{% for image in role.get('container_images', []) %}",
"{% if image.get('engine') == 'docker' and image.get('pull_ref') %}", "{% if image.get('engine') == 'docker' and image.get('pull_ref') %}",
f"enroll_docker_pull_{role_key}_{{{{ loop.index }}}}:", f"enroll_docker_pull_{role_key}_{{{{ loop.index }}}}:",
@ -980,6 +1486,31 @@ def _render_pillar_role(srole: SaltRole) -> str:
"{% endif %}", "{% endif %}",
"{% endfor %}", "{% 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', {}) %}", "{% if role.get('sysctl_apply') and '/etc/sysctl.d/99-enroll.conf' in role.get('files', {}) %}",
f"enroll_apply_sysctl_{role_key}:", f"enroll_apply_sysctl_{role_key}:",
" cmd.run:", " 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. - `/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. - Docker images by digest using guarded `docker pull` / `docker tag` command states.
- Podman images by digest using guarded `podman pull` / `podman 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 ## 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. - 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.

View file

@ -2,4 +2,4 @@
set -eou pipefail set -eou pipefail
poetry run pytest -q tests -vvv --cov=enroll poetry run pytest -q tests -vvv --cov=enroll --cov-report=term-missing

View file

@ -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/sshd_config")
assert can_jinjify_path("/etc/ssh/ssh_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()

View file

@ -1308,9 +1308,14 @@ def test_manifest_writes_firewall_runtime_role(tmp_path: Path):
tasks = (out / "roles" / "firewall_runtime" / "tasks" / "main.yml").read_text( tasks = (out / "roles" / "firewall_runtime" / "tasks" / "main.yml").read_text(
encoding="utf-8" encoding="utf-8"
) )
assert "ipset restore -exist" in tasks handlers = (out / "roles" / "firewall_runtime" / "handlers" / "main.yml").read_text(
assert "iptables-restore /etc/enroll/firewall/iptables.v4" in tasks encoding="utf-8"
assert "ipset flush {{ item }}" in tasks )
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( defaults = (out / "roles" / "firewall_runtime" / "defaults" / "main.yml").read_text(
encoding="utf-8" 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 assert "firewall_runtime_restore_iptables: true" in defaults
pb = (out / "playbook.yml").read_text(encoding="utf-8") pb = (out / "playbook.yml").read_text(encoding="utf-8")
assert "role: enroll_runtime" in pb
assert "role: firewall_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 ( assert (
out / "roles" / "firewall_runtime" / "files" / "firewall" / "ipset.save" out / "roles" / "firewall_runtime" / "files" / "firewall" / "ipset.save"
).exists() ).exists()
@ -2076,6 +2087,8 @@ def test_manifest_renders_container_image_role_for_ansible(tmp_path: Path):
assert podman_digest in defaults assert podman_digest in defaults
assert "community.docker.docker_image_pull" in tasks assert "community.docker.docker_image_pull" in tasks
assert "community.docker.docker_image_tag" 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_image" in tasks
assert "containers.podman.podman_tag" in tasks assert "containers.podman.podman_tag" in tasks
assert "repository:" in tasks assert "repository:" in tasks

View file

@ -712,3 +712,89 @@ def test_manifest_puppet_renders_container_images_static_and_hiera(tmp_path: Pat
assert "podman pull" in ( assert "podman pull" in (
fqdn_out / "data" / "nodes" / "node.example.yaml" fqdn_out / "data" / "nodes" / "node.example.yaml"
).read_text(encoding="utf-8") ).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

View file

@ -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["source"] == "salt://roles/foo/templates/etc/foo.conf.j2"
assert file_data["template"] == "jinja" assert file_data["template"] == "jinja"
assert file_data["context"] == {"foo_setting": True} 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