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

View file

@ -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)

View file

@ -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,
}

View file

@ -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

View file

@ -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.

View file

@ -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.