loooots of fixes.
This commit is contained in:
parent
b8926f9a5f
commit
de42e16510
12 changed files with 1579 additions and 116 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
577
enroll/puppet.py
577
enroll/puppet.py
|
|
@ -29,9 +29,20 @@ class PuppetRole(CMModule):
|
|||
module_name=_puppet_name(role_name, fallback="enroll_role"),
|
||||
)
|
||||
self.container_images: List[Dict[str, Any]] = []
|
||||
self.flatpak_remotes: List[Dict[str, Any]] = []
|
||||
self.flatpaks: List[Dict[str, Any]] = []
|
||||
self.snaps: List[Dict[str, Any]] = []
|
||||
self.firewall_runtime: Dict[str, Any] = {}
|
||||
|
||||
def has_resources(self) -> bool:
|
||||
return super().has_resources() or bool(self.container_images)
|
||||
return (
|
||||
super().has_resources()
|
||||
or bool(self.container_images)
|
||||
or bool(self.flatpak_remotes)
|
||||
or bool(self.flatpaks)
|
||||
or bool(self.snaps)
|
||||
or bool(self.firewall_runtime)
|
||||
)
|
||||
|
||||
def add_package_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
pkg = str(snap.get("package") or "").strip()
|
||||
|
|
@ -83,10 +94,108 @@ class PuppetRole(CMModule):
|
|||
"supplementary_groups": supplementary,
|
||||
}
|
||||
|
||||
if snap.get("user_flatpaks") or snap.get("user_flatpak_remotes"):
|
||||
self.notes.append(
|
||||
"Per-user Flatpak resources were detected but are not yet rendered as native Puppet resources."
|
||||
home_by_user = {
|
||||
str(u.get("name")): str(u.get("home") or "")
|
||||
for u in (snap.get("users", []) or [])
|
||||
if isinstance(u, dict) and u.get("name")
|
||||
}
|
||||
for remote in snap.get("user_flatpak_remotes", []) or []:
|
||||
item = _normalise_flatpak_remote(remote)
|
||||
user = str(item.get("user") or "").strip()
|
||||
if user and not item.get("home"):
|
||||
item["home"] = home_by_user.get(user) or f"/home/{user}"
|
||||
if item.get("method") == "user" and item.get("name") and item.get("url"):
|
||||
self.flatpak_remotes.append(_prepare_flatpak_remote(item))
|
||||
for uname, flatpaks in (snap.get("user_flatpaks", {}) or {}).items():
|
||||
user = str(uname)
|
||||
for fp in flatpaks or []:
|
||||
item = _normalise_flatpak_item(
|
||||
fp, method="user", user=user, home=home_by_user.get(user) or None
|
||||
)
|
||||
if item.get("name"):
|
||||
self.flatpaks.append(_prepare_flatpak_item(item))
|
||||
|
||||
def add_flatpak_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
for remote in snap.get("remotes", []) or []:
|
||||
item = _normalise_flatpak_remote(remote)
|
||||
if item.get("name") and item.get("url"):
|
||||
self.flatpak_remotes.append(_prepare_flatpak_remote(item))
|
||||
for fp in snap.get("system_flatpaks", []) or []:
|
||||
item = _normalise_flatpak_item(fp, method="system")
|
||||
if item.get("name"):
|
||||
self.flatpaks.append(_prepare_flatpak_item(item))
|
||||
for note in snap.get("notes", []) or []:
|
||||
self.notes.append(str(note))
|
||||
|
||||
def add_snap_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
for raw in snap.get("system_snaps", []) or []:
|
||||
item = _normalise_snap_item(raw)
|
||||
if item.get("name"):
|
||||
self.snaps.append(_prepare_snap_item(item))
|
||||
for note in snap.get("notes", []) or []:
|
||||
self.notes.append(str(note))
|
||||
|
||||
def add_firewall_runtime_snapshot(
|
||||
self,
|
||||
snap: Dict[str, Any],
|
||||
*,
|
||||
bundle_dir: str,
|
||||
artifact_role: str,
|
||||
module_files_dir: Path,
|
||||
file_prefix: Optional[str] = None,
|
||||
) -> None:
|
||||
self.packages.update(
|
||||
str(p).strip() for p in (snap.get("packages") or []) if str(p).strip()
|
||||
)
|
||||
self.add_managed_dir(
|
||||
"/etc/enroll/firewall",
|
||||
owner="root",
|
||||
group="root",
|
||||
mode="0750",
|
||||
require="File['/etc/enroll']",
|
||||
reason="firewall_runtime",
|
||||
)
|
||||
runtime: Dict[str, Any] = {}
|
||||
for key, dest_name, mode in (
|
||||
("ipset_save", "ipset.save", "0600"),
|
||||
("iptables_v4_save", "iptables.v4", "0600"),
|
||||
("iptables_v6_save", "iptables.v6", "0600"),
|
||||
):
|
||||
src_rel = str(snap.get(key) or "").strip()
|
||||
if not src_rel:
|
||||
continue
|
||||
role_rel = _copy_artifact(
|
||||
bundle_dir,
|
||||
artifact_role,
|
||||
src_rel,
|
||||
module_files_dir,
|
||||
dst_prefix=file_prefix,
|
||||
)
|
||||
if not role_rel:
|
||||
self.notes.append(
|
||||
f"Firewall runtime artifact {src_rel!r} was referenced but not found."
|
||||
)
|
||||
continue
|
||||
dest = f"/etc/enroll/firewall/{dest_name}"
|
||||
self.add_managed_file(
|
||||
dest,
|
||||
owner="root",
|
||||
group="root",
|
||||
mode=mode,
|
||||
source=_source_uri(self.module_name, role_rel),
|
||||
reason="firewall_runtime",
|
||||
)
|
||||
runtime[key] = dest
|
||||
ipset_sets = [
|
||||
str(x).strip() for x in (snap.get("ipset_sets") or []) if str(x).strip()
|
||||
]
|
||||
if ipset_sets:
|
||||
runtime["ipset_sets"] = ipset_sets
|
||||
if runtime:
|
||||
runtime.update(_firewall_runtime_commands(runtime))
|
||||
self.firewall_runtime.update(runtime)
|
||||
for note in snap.get("notes", []) or []:
|
||||
self.notes.append(str(note))
|
||||
|
||||
def add_container_images_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
for raw in snap.get("images", []) or []:
|
||||
|
|
@ -265,10 +374,204 @@ def _container_tag_cmd(engine: str, pull_ref: str, tag_ref: str) -> str:
|
|||
return f"{engine} tag {_shell_quote(pull_ref)} {_shell_quote(tag_ref)}"
|
||||
|
||||
|
||||
def _normalise_flatpak_item(
|
||||
item: Dict[str, Any],
|
||||
*,
|
||||
method: str,
|
||||
user: Optional[str] = None,
|
||||
home: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
out = dict(item)
|
||||
out["method"] = str(out.get("method") or method or "system").strip() or "system"
|
||||
if user and not out.get("user"):
|
||||
out["user"] = user
|
||||
if home and not out.get("home"):
|
||||
out["home"] = home
|
||||
ref = str(out.get("ref") or "").strip()
|
||||
if ref and not out.get("name"):
|
||||
out["name"] = ref.rsplit("/", 1)[-1]
|
||||
name = str(out.get("name") or out.get("app_id") or "").strip()
|
||||
if name:
|
||||
out["name"] = name
|
||||
remote = str(out.get("remote") or "").strip()
|
||||
if remote:
|
||||
out["remote"] = remote
|
||||
branch = str(out.get("branch") or out.get("origin") or "").strip()
|
||||
if branch:
|
||||
out["branch"] = branch
|
||||
if ref:
|
||||
out["ref"] = ref
|
||||
return out
|
||||
|
||||
|
||||
def _normalise_flatpak_remote(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = dict(item)
|
||||
name = str(out.get("name") or out.get("remote") or "").strip()
|
||||
url = str(out.get("url") or out.get("from_url") or "").strip()
|
||||
method = str(out.get("method") or out.get("scope") or "system").strip() or "system"
|
||||
if name:
|
||||
out["name"] = name
|
||||
if url:
|
||||
out["url"] = url
|
||||
out["method"] = "user" if method == "user" else "system"
|
||||
return out
|
||||
|
||||
|
||||
def _normalise_snap_item(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = dict(item)
|
||||
name = str(out.get("name") or "").strip()
|
||||
if name:
|
||||
out["name"] = name
|
||||
channel = str(out.get("tracking") or out.get("channel") or "").strip()
|
||||
if channel:
|
||||
out["channel"] = channel
|
||||
notes = [str(note).lower() for note in (out.get("notes") or [])]
|
||||
confinement = str(out.get("confinement") or "").strip().lower()
|
||||
out["classic"] = confinement == "classic" or any(
|
||||
"classic" in note for note in notes
|
||||
)
|
||||
out["devmode"] = any("devmode" in note or "dev mode" in note for note in notes)
|
||||
out["dangerous"] = any("dangerous" in note for note in notes)
|
||||
revision = str(out.get("revision") or "").strip()
|
||||
if revision and not channel:
|
||||
out["revision"] = revision
|
||||
return out
|
||||
|
||||
|
||||
def _flatpak_scope(item: Dict[str, Any]) -> str:
|
||||
return "--user" if str(item.get("method") or "system") == "user" else "--system"
|
||||
|
||||
|
||||
def _flatpak_home(item: Dict[str, Any]) -> Optional[str]:
|
||||
user = str(item.get("user") or "").strip()
|
||||
if not user:
|
||||
return None
|
||||
return str(item.get("home") or f"/home/{user}")
|
||||
|
||||
|
||||
def _flatpak_exec_env(item: Dict[str, Any]) -> List[str]:
|
||||
home = _flatpak_home(item)
|
||||
if not home:
|
||||
return []
|
||||
return [f"HOME={home}", f"XDG_DATA_HOME={home}/.local/share"]
|
||||
|
||||
|
||||
def _flatpak_remote_exists_cmd(item: Dict[str, Any]) -> str:
|
||||
return (
|
||||
f"flatpak {_flatpak_scope(item)} remote-list --columns=name "
|
||||
f"| grep -Fx -- {_shell_quote(item.get('name'))}"
|
||||
)
|
||||
|
||||
|
||||
def _flatpak_remote_add_cmd(item: Dict[str, Any]) -> str:
|
||||
return (
|
||||
f"flatpak {_flatpak_scope(item)} remote-add --if-not-exists "
|
||||
f"{_shell_quote(item.get('name'))} {_shell_quote(item.get('url'))}"
|
||||
)
|
||||
|
||||
|
||||
def _flatpak_ref(item: Dict[str, Any]) -> str:
|
||||
ref = str(item.get("ref") or "").strip()
|
||||
if ref:
|
||||
return ref
|
||||
return str(item.get("name") or "").strip()
|
||||
|
||||
|
||||
def _flatpak_exists_cmd(item: Dict[str, Any]) -> str:
|
||||
return f"flatpak {_flatpak_scope(item)} info {_shell_quote(_flatpak_ref(item))} >/dev/null 2>&1"
|
||||
|
||||
|
||||
def _flatpak_install_cmd(item: Dict[str, Any]) -> str:
|
||||
args = ["flatpak", _flatpak_scope(item), "install", "-y"]
|
||||
remote = str(item.get("remote") or "").strip()
|
||||
if remote:
|
||||
args.append(remote)
|
||||
args.append(_flatpak_ref(item))
|
||||
return " ".join(_shell_quote(arg) for arg in args)
|
||||
|
||||
|
||||
def _prepare_flatpak_remote(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = dict(item)
|
||||
method = str(out.get("method") or "system")
|
||||
user = str(out.get("user") or "")
|
||||
name = str(out.get("name") or "")
|
||||
out["state_id"] = _state_title("flatpak-remote", f"{method}-{user}-{name}")
|
||||
out["add_cmd"] = _flatpak_remote_add_cmd(out)
|
||||
out["exists_cmd"] = _flatpak_remote_exists_cmd(out)
|
||||
out["environment"] = _flatpak_exec_env(out)
|
||||
return out
|
||||
|
||||
|
||||
def _prepare_flatpak_item(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = dict(item)
|
||||
method = str(out.get("method") or "system")
|
||||
user = str(out.get("user") or "")
|
||||
ref = _flatpak_ref(out)
|
||||
out["state_id"] = _state_title("flatpak", f"{method}-{user}-{ref}")
|
||||
out["install_cmd"] = _flatpak_install_cmd(out)
|
||||
out["exists_cmd"] = _flatpak_exists_cmd(out)
|
||||
out["environment"] = _flatpak_exec_env(out)
|
||||
return out
|
||||
|
||||
|
||||
def _snap_exists_cmd(item: Dict[str, Any]) -> str:
|
||||
return f"snap list {_shell_quote(item.get('name'))} >/dev/null 2>&1"
|
||||
|
||||
|
||||
def _snap_install_cmd(item: Dict[str, Any]) -> str:
|
||||
args = ["snap", "install", str(item.get("name") or "")]
|
||||
channel = str(item.get("channel") or "").strip()
|
||||
revision = str(item.get("revision") or "").strip()
|
||||
if channel:
|
||||
args.append(f"--channel={channel}")
|
||||
elif revision:
|
||||
args.append(f"--revision={revision}")
|
||||
if item.get("classic"):
|
||||
args.append("--classic")
|
||||
if item.get("devmode"):
|
||||
args.append("--devmode")
|
||||
if item.get("dangerous"):
|
||||
args.append("--dangerous")
|
||||
return " ".join(_shell_quote(arg) for arg in args if str(arg))
|
||||
|
||||
|
||||
def _prepare_snap_item(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = dict(item)
|
||||
name = str(out.get("name") or "")
|
||||
out["state_id"] = _state_title("snap", name)
|
||||
out["install_cmd"] = _snap_install_cmd(out)
|
||||
out["exists_cmd"] = _snap_exists_cmd(out)
|
||||
return out
|
||||
|
||||
|
||||
def _pp_array(values: Iterable[Any]) -> str:
|
||||
return "[" + ", ".join(_pp_quote(v) for v in values) + "]"
|
||||
|
||||
|
||||
def _puppet_exec_attrs(
|
||||
command: str,
|
||||
unless: str,
|
||||
*,
|
||||
item: Optional[Dict[str, Any]] = None,
|
||||
require: Optional[str] = None,
|
||||
) -> List[Tuple[str, str]]:
|
||||
attrs: List[Tuple[str, str]] = [
|
||||
("command", _pp_quote(command)),
|
||||
("unless", _pp_quote(unless)),
|
||||
("path", "['/usr/bin', '/bin']"),
|
||||
]
|
||||
if item:
|
||||
user = str(item.get("user") or "").strip()
|
||||
if user:
|
||||
attrs.append(("user", _pp_quote(user)))
|
||||
env = item.get("environment") or _flatpak_exec_env(item)
|
||||
if env:
|
||||
attrs.append(("environment", _pp_array(env)))
|
||||
if require:
|
||||
attrs.append(("require", require))
|
||||
return attrs
|
||||
|
||||
|
||||
def _resource(
|
||||
lines: List[str], rtype: str, title: str, attrs: List[Tuple[str, str]]
|
||||
) -> None:
|
||||
|
|
@ -293,6 +596,71 @@ def _state_title(prefix: str, value: Any) -> str:
|
|||
return f"enroll-{prefix}-{safe}"
|
||||
|
||||
|
||||
def _firewall_ipset_restore_cmd(path: str, sets: List[str]) -> str:
|
||||
flush_parts = [f"ipset flush {_shell_quote(name)} || true" for name in sets]
|
||||
flush = "; ".join(flush_parts)
|
||||
restore = f"ipset restore -exist < {_shell_quote(path)}"
|
||||
if flush:
|
||||
return f"/bin/sh -c {_shell_quote(flush + '; ' + restore)}"
|
||||
return f"/bin/sh -c {_shell_quote(restore)}"
|
||||
|
||||
|
||||
def _firewall_runtime_commands(runtime: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {}
|
||||
ipset_path = str(runtime.get("ipset_save") or "")
|
||||
if ipset_path:
|
||||
sets = [str(x) for x in (runtime.get("ipset_sets") or []) if str(x)]
|
||||
out["ipset_restore_cmd"] = _firewall_ipset_restore_cmd(ipset_path, sets)
|
||||
ipt4_path = str(runtime.get("iptables_v4_save") or "")
|
||||
if ipt4_path:
|
||||
out["iptables_v4_restore_cmd"] = f"iptables-restore {_shell_quote(ipt4_path)}"
|
||||
ipt6_path = str(runtime.get("iptables_v6_save") or "")
|
||||
if ipt6_path:
|
||||
out["iptables_v6_restore_cmd"] = f"ip6tables-restore {_shell_quote(ipt6_path)}"
|
||||
return out
|
||||
|
||||
|
||||
def _render_firewall_runtime_execs(
|
||||
lines: List[str], runtime: Dict[str, Any], *, indent: str = " "
|
||||
) -> None:
|
||||
specs = [
|
||||
(
|
||||
"ipset",
|
||||
"ipset_save",
|
||||
"ipset_restore_cmd",
|
||||
"enroll-firewall-runtime-ipset-restore",
|
||||
),
|
||||
(
|
||||
"iptables_v4",
|
||||
"iptables_v4_save",
|
||||
"iptables_v4_restore_cmd",
|
||||
"enroll-firewall-runtime-iptables-v4-restore",
|
||||
),
|
||||
(
|
||||
"iptables_v6",
|
||||
"iptables_v6_save",
|
||||
"iptables_v6_restore_cmd",
|
||||
"enroll-firewall-runtime-iptables-v6-restore",
|
||||
),
|
||||
]
|
||||
for _family, path_key, cmd_key, title in specs:
|
||||
path = str(runtime.get(path_key) or "")
|
||||
command = str(runtime.get(cmd_key) or "")
|
||||
if not path or not command:
|
||||
continue
|
||||
attrs: List[Tuple[str, str]] = [
|
||||
("command", _pp_quote(command)),
|
||||
("path", "['/sbin', '/usr/sbin', '/bin', '/usr/bin']"),
|
||||
("refreshonly", "true"),
|
||||
("subscribe", f"File[{_pp_quote(path)}]"),
|
||||
]
|
||||
lines.append(f"{indent}exec {{ {_pp_quote(title)}:")
|
||||
for key, value in attrs:
|
||||
lines.append(f"{indent} {key} => {value},")
|
||||
lines.append(f"{indent}}}")
|
||||
lines.append("")
|
||||
|
||||
|
||||
def _copy_artifact(
|
||||
bundle_dir: str,
|
||||
role: str,
|
||||
|
|
@ -317,23 +685,6 @@ def _source_uri(module_name: str, module_rel: str) -> str:
|
|||
return f"puppet:///modules/{module_name}/{module_rel}"
|
||||
|
||||
|
||||
def _add_flatpak_snap_notes(roles: Dict[str, Any], out: Dict[str, PuppetRole]) -> None:
|
||||
flatpak = roles.get("flatpak") or {}
|
||||
if isinstance(flatpak, dict) and (
|
||||
flatpak.get("system_flatpaks") or flatpak.get("remotes")
|
||||
):
|
||||
prole = out.setdefault("flatpak", PuppetRole("flatpak"))
|
||||
prole.notes.append(
|
||||
"Flatpak resources were detected but are not yet rendered as native Puppet resources."
|
||||
)
|
||||
snap = roles.get("snap") or {}
|
||||
if isinstance(snap, dict) and snap.get("system_snaps"):
|
||||
prole = out.setdefault("snap", PuppetRole("snap"))
|
||||
prole.notes.append(
|
||||
"Snap resources were detected but are not yet rendered as native Puppet resources."
|
||||
)
|
||||
|
||||
|
||||
def _node_data_filename(fqdn: str) -> str:
|
||||
"""Return a safe Hiera node-data filename for an FQDN/certname."""
|
||||
|
||||
|
|
@ -480,15 +831,37 @@ def _collect_puppet_roles(
|
|||
packages = [
|
||||
str(p).strip() for p in (fw.get("packages") or []) if str(p).strip()
|
||||
]
|
||||
if has_fw or packages:
|
||||
prole = ensure_role(str(fw.get("role_name") or "firewall_runtime"))
|
||||
prole.packages.update(packages)
|
||||
if has_fw or packages or fw.get("notes"):
|
||||
if has_fw:
|
||||
prole.notes.append(
|
||||
"Live firewall runtime snapshots were detected but are not yet rendered as Puppet resources."
|
||||
runtime_role = ensure_role("enroll_runtime")
|
||||
runtime_role.add_managed_dir(
|
||||
"/etc/enroll",
|
||||
owner="root",
|
||||
group="root",
|
||||
mode="0750",
|
||||
reason="enroll_runtime",
|
||||
)
|
||||
role_name = str(fw.get("role_name") or "firewall_runtime")
|
||||
prole = ensure_role(role_name)
|
||||
prole.add_firewall_runtime_snapshot(
|
||||
fw,
|
||||
bundle_dir=bundle_dir,
|
||||
artifact_role=role_name,
|
||||
module_files_dir=modules_dir / prole.module_name / "files",
|
||||
file_prefix=node_file_prefix,
|
||||
)
|
||||
|
||||
_add_flatpak_snap_notes(roles, out)
|
||||
flatpak = roles.get("flatpak") or {}
|
||||
if isinstance(flatpak, dict) and (
|
||||
flatpak.get("system_flatpaks") or flatpak.get("remotes") or flatpak.get("notes")
|
||||
):
|
||||
prole = ensure_role(str(flatpak.get("role_name") or "flatpak"))
|
||||
prole.add_flatpak_snapshot(flatpak)
|
||||
|
||||
snap = roles.get("snap") or {}
|
||||
if isinstance(snap, dict) and (snap.get("system_snaps") or snap.get("notes")):
|
||||
prole = ensure_role(str(snap.get("role_name") or "snap"))
|
||||
prole.add_snap_snapshot(snap)
|
||||
|
||||
puppet_roles = sorted(out.values(), key=lambda r: role_order_key(r.role_name))
|
||||
resolve_catalog_conflicts(puppet_roles)
|
||||
|
|
@ -549,6 +922,7 @@ def _render_role_class(prole: PuppetRole) -> str:
|
|||
("owner", _pp_quote(d.get("owner") or "root")),
|
||||
("group", _pp_quote(d.get("group") or "root")),
|
||||
("mode", _pp_quote(d.get("mode") or "0755")),
|
||||
*([("require", str(d.get("require")))] if d.get("require") else []),
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -588,6 +962,82 @@ def _render_role_class(prole: PuppetRole) -> str:
|
|||
],
|
||||
)
|
||||
|
||||
flatpak_remote_titles: Dict[Tuple[str, str, str], str] = {}
|
||||
for remote in prole.flatpak_remotes:
|
||||
name = str(remote.get("name") or "").strip()
|
||||
url = str(remote.get("url") or "").strip()
|
||||
if not name or not url:
|
||||
continue
|
||||
title = str(remote.get("state_id") or _state_title("flatpak-remote", name))
|
||||
key = (
|
||||
str(remote.get("method") or "system"),
|
||||
str(remote.get("user") or ""),
|
||||
name,
|
||||
)
|
||||
flatpak_remote_titles[key] = title
|
||||
remote_user = str(remote.get("user") or "").strip()
|
||||
remote_require = None
|
||||
if remote_user and remote_user in prole.users:
|
||||
remote_require = f"User[{_pp_quote(remote_user)}]"
|
||||
_resource(
|
||||
lines,
|
||||
"exec",
|
||||
title,
|
||||
_puppet_exec_attrs(
|
||||
str(remote.get("add_cmd") or _flatpak_remote_add_cmd(remote)),
|
||||
str(remote.get("exists_cmd") or _flatpak_remote_exists_cmd(remote)),
|
||||
item=remote,
|
||||
require=remote_require,
|
||||
),
|
||||
)
|
||||
|
||||
for app in prole.flatpaks:
|
||||
ref = _flatpak_ref(app)
|
||||
if not ref:
|
||||
continue
|
||||
title = str(app.get("state_id") or _state_title("flatpak", ref))
|
||||
requires: List[str] = []
|
||||
user = str(app.get("user") or "").strip()
|
||||
if user:
|
||||
requires.append(f"User[{_pp_quote(user)}]")
|
||||
remote = str(app.get("remote") or "").strip()
|
||||
if remote:
|
||||
remote_title = flatpak_remote_titles.get(
|
||||
(str(app.get("method") or "system"), user, remote)
|
||||
)
|
||||
if remote_title:
|
||||
requires.append(f"Exec[{_pp_quote(remote_title)}]")
|
||||
require_expr = None
|
||||
if len(requires) == 1:
|
||||
require_expr = requires[0]
|
||||
elif requires:
|
||||
require_expr = "[" + ", ".join(requires) + "]"
|
||||
_resource(
|
||||
lines,
|
||||
"exec",
|
||||
title,
|
||||
_puppet_exec_attrs(
|
||||
str(app.get("install_cmd") or _flatpak_install_cmd(app)),
|
||||
str(app.get("exists_cmd") or _flatpak_exists_cmd(app)),
|
||||
item=app,
|
||||
require=require_expr,
|
||||
),
|
||||
)
|
||||
|
||||
for snap in prole.snaps:
|
||||
name = str(snap.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
_resource(
|
||||
lines,
|
||||
"exec",
|
||||
str(snap.get("state_id") or _state_title("snap", name)),
|
||||
_puppet_exec_attrs(
|
||||
str(snap.get("install_cmd") or _snap_install_cmd(snap)),
|
||||
str(snap.get("exists_cmd") or _snap_exists_cmd(snap)),
|
||||
),
|
||||
)
|
||||
|
||||
for image in prole.container_images:
|
||||
engine = str(image.get("engine") or "").strip()
|
||||
pull_ref = str(image.get("pull_ref") or "").strip()
|
||||
|
|
@ -698,6 +1148,9 @@ def _render_role_class(prole: PuppetRole) -> str:
|
|||
],
|
||||
)
|
||||
|
||||
if prole.firewall_runtime:
|
||||
_render_firewall_runtime_execs(lines, prole.firewall_runtime)
|
||||
|
||||
if has_sysctl_conf:
|
||||
lines.append(" if $sysctl_apply {")
|
||||
lines.append(" exec { 'enroll-apply-sysctl':")
|
||||
|
|
@ -776,7 +1229,7 @@ def _role_hiera_values(prole: PuppetRole) -> Dict[str, Any]:
|
|||
path: _attrs_with_ensure(
|
||||
prole.dirs[path],
|
||||
"directory",
|
||||
allowed={"owner", "group", "mode"},
|
||||
allowed={"owner", "group", "mode", "require"},
|
||||
)
|
||||
for path in sorted(prole.dirs)
|
||||
}
|
||||
|
|
@ -810,8 +1263,16 @@ def _role_hiera_values(prole: PuppetRole) -> Dict[str, Any]:
|
|||
for name in sorted(prole.services)
|
||||
}
|
||||
|
||||
if prole.flatpak_remotes:
|
||||
data[f"{prefix}flatpak_remotes"] = list(prole.flatpak_remotes)
|
||||
if prole.flatpaks:
|
||||
data[f"{prefix}flatpaks"] = list(prole.flatpaks)
|
||||
if prole.snaps:
|
||||
data[f"{prefix}snaps"] = list(prole.snaps)
|
||||
if prole.container_images:
|
||||
data[f"{prefix}container_images"] = list(prole.container_images)
|
||||
if prole.firewall_runtime:
|
||||
data[f"{prefix}firewall_runtime"] = dict(prole.firewall_runtime)
|
||||
|
||||
if prole.notes:
|
||||
data[f"{prefix}notes"] = list(prole.notes)
|
||||
|
|
@ -837,7 +1298,11 @@ def _render_hiera_role_class(prole: PuppetRole) -> str:
|
|||
" Hash[String, Hash] $files = {},",
|
||||
" Hash[String, Hash] $links = {},",
|
||||
" Hash[String, Hash] $services = {},",
|
||||
" Array[Hash] $flatpak_remotes = [],",
|
||||
" Array[Hash] $flatpaks = [],",
|
||||
" Array[Hash] $snaps = [],",
|
||||
" Array[Hash] $container_images = [],",
|
||||
" Hash $firewall_runtime = {},",
|
||||
" Array[String] $notes = [],",
|
||||
" Boolean $sysctl_apply = true,",
|
||||
" Boolean $sysctl_ignore_apply_errors = true,",
|
||||
|
|
@ -885,6 +1350,34 @@ def _render_hiera_role_class(prole: PuppetRole) -> str:
|
|||
" }",
|
||||
" }",
|
||||
"",
|
||||
" $flatpak_remotes.each |Integer $idx, Hash $remote| {",
|
||||
" exec { $remote['state_id']:",
|
||||
" command => $remote['add_cmd'],",
|
||||
" unless => $remote['exists_cmd'],",
|
||||
" path => ['/usr/bin', '/bin'],",
|
||||
" user => $remote['user'],",
|
||||
" environment => $remote['environment'],",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" $flatpaks.each |Integer $idx, Hash $app| {",
|
||||
" exec { $app['state_id']:",
|
||||
" command => $app['install_cmd'],",
|
||||
" unless => $app['exists_cmd'],",
|
||||
" path => ['/usr/bin', '/bin'],",
|
||||
" user => $app['user'],",
|
||||
" environment => $app['environment'],",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" $snaps.each |Integer $idx, Hash $snap| {",
|
||||
" exec { $snap['state_id']:",
|
||||
" command => $snap['install_cmd'],",
|
||||
" unless => $snap['exists_cmd'],",
|
||||
" path => ['/usr/bin', '/bin'],",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" $container_images.each |Integer $idx, Hash $image| {",
|
||||
" if $image['engine'] == 'docker' and $image['pull_ref'] {",
|
||||
' exec { "enroll-docker-pull-${idx}":',
|
||||
|
|
@ -917,6 +1410,33 @@ def _render_hiera_role_class(prole: PuppetRole) -> str:
|
|||
" }",
|
||||
" }",
|
||||
"",
|
||||
" if $firewall_runtime['ipset_restore_cmd'] {",
|
||||
" exec { 'enroll-firewall-runtime-ipset-restore':",
|
||||
" command => $firewall_runtime['ipset_restore_cmd'],",
|
||||
" path => ['/sbin', '/usr/sbin', '/bin', '/usr/bin'],",
|
||||
" refreshonly => true,",
|
||||
" subscribe => File[$firewall_runtime['ipset_save']],",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" if $firewall_runtime['iptables_v4_restore_cmd'] {",
|
||||
" exec { 'enroll-firewall-runtime-iptables-v4-restore':",
|
||||
" command => $firewall_runtime['iptables_v4_restore_cmd'],",
|
||||
" path => ['/sbin', '/usr/sbin', '/bin', '/usr/bin'],",
|
||||
" refreshonly => true,",
|
||||
" subscribe => File[$firewall_runtime['iptables_v4_save']],",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" if $firewall_runtime['iptables_v6_restore_cmd'] {",
|
||||
" exec { 'enroll-firewall-runtime-iptables-v6-restore':",
|
||||
" command => $firewall_runtime['iptables_v6_restore_cmd'],",
|
||||
" path => ['/sbin', '/usr/sbin', '/bin', '/usr/bin'],",
|
||||
" refreshonly => true,",
|
||||
" subscribe => File[$firewall_runtime['iptables_v6_save']],",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" if $sysctl_apply and '/etc/sysctl.d/99-enroll.conf' in $files {",
|
||||
" exec { 'enroll-apply-sysctl':",
|
||||
" command => $sysctl_ignore_apply_errors ? {",
|
||||
|
|
@ -1145,7 +1665,6 @@ This Puppet target reuses the existing harvest state without changing harvesting
|
|||
|
||||
## Current limitations
|
||||
|
||||
- Flatpak, Snap, and live firewall runtime snapshots are listed as notes when present rather than rendered as Puppet resources.
|
||||
- JinjaTurtle templating is currently Ansible-oriented and is not applied to Puppet output.
|
||||
- Review generated resources before applying them broadly across unlike hosts.
|
||||
|
||||
|
|
|
|||
591
enroll/salt.py
591
enroll/salt.py
|
|
@ -31,9 +31,20 @@ class SaltRole(CMModule):
|
|||
module_name=_salt_name(role_name, fallback="enroll_role"),
|
||||
)
|
||||
self.container_images: List[Dict[str, Any]] = []
|
||||
self.flatpak_remotes: List[Dict[str, Any]] = []
|
||||
self.flatpaks: List[Dict[str, Any]] = []
|
||||
self.snaps: List[Dict[str, Any]] = []
|
||||
self.firewall_runtime: Dict[str, Any] = {}
|
||||
|
||||
def has_resources(self) -> bool:
|
||||
return super().has_resources() or bool(self.container_images)
|
||||
return (
|
||||
super().has_resources()
|
||||
or bool(self.container_images)
|
||||
or bool(self.flatpak_remotes)
|
||||
or bool(self.flatpaks)
|
||||
or bool(self.snaps)
|
||||
or bool(self.firewall_runtime)
|
||||
)
|
||||
|
||||
@property
|
||||
def sls_name(self) -> str:
|
||||
|
|
@ -87,10 +98,108 @@ class SaltRole(CMModule):
|
|||
user_data.update(_gecos_attrs(u.get("gecos")))
|
||||
self.users[name] = user_data
|
||||
|
||||
if snap.get("user_flatpaks") or snap.get("user_flatpak_remotes"):
|
||||
self.notes.append(
|
||||
"Per-user Flatpak resources were detected but are not rendered as native Salt states."
|
||||
home_by_user = {
|
||||
str(u.get("name")): str(u.get("home") or "")
|
||||
for u in (snap.get("users", []) or [])
|
||||
if isinstance(u, dict) and u.get("name")
|
||||
}
|
||||
for remote in snap.get("user_flatpak_remotes", []) or []:
|
||||
item = _normalise_flatpak_remote(remote)
|
||||
user = str(item.get("user") or "").strip()
|
||||
if user and not item.get("home"):
|
||||
item["home"] = home_by_user.get(user) or f"/home/{user}"
|
||||
if item.get("method") == "user" and item.get("name") and item.get("url"):
|
||||
self.flatpak_remotes.append(_prepare_flatpak_remote(item))
|
||||
for uname, flatpaks in (snap.get("user_flatpaks", {}) or {}).items():
|
||||
user = str(uname)
|
||||
for fp in flatpaks or []:
|
||||
item = _normalise_flatpak_item(
|
||||
fp, method="user", user=user, home=home_by_user.get(user) or None
|
||||
)
|
||||
if item.get("name"):
|
||||
self.flatpaks.append(_prepare_flatpak_item(item))
|
||||
|
||||
def add_flatpak_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
for remote in snap.get("remotes", []) or []:
|
||||
item = _normalise_flatpak_remote(remote)
|
||||
if item.get("name") and item.get("url"):
|
||||
self.flatpak_remotes.append(_prepare_flatpak_remote(item))
|
||||
for fp in snap.get("system_flatpaks", []) or []:
|
||||
item = _normalise_flatpak_item(fp, method="system")
|
||||
if item.get("name"):
|
||||
self.flatpaks.append(_prepare_flatpak_item(item))
|
||||
for note in snap.get("notes", []) or []:
|
||||
self.notes.append(str(note))
|
||||
|
||||
def add_snap_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
for raw in snap.get("system_snaps", []) or []:
|
||||
item = _normalise_snap_item(raw)
|
||||
if item.get("name"):
|
||||
self.snaps.append(_prepare_snap_item(item))
|
||||
for note in snap.get("notes", []) or []:
|
||||
self.notes.append(str(note))
|
||||
|
||||
def add_firewall_runtime_snapshot(
|
||||
self,
|
||||
snap: Dict[str, Any],
|
||||
*,
|
||||
bundle_dir: str,
|
||||
artifact_role: str,
|
||||
role_files_dir: Path,
|
||||
file_prefix: Optional[str] = None,
|
||||
) -> None:
|
||||
self.packages.update(
|
||||
str(p).strip() for p in (snap.get("packages") or []) if str(p).strip()
|
||||
)
|
||||
self.add_managed_dir(
|
||||
"/etc/enroll/firewall",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="0750",
|
||||
require=[{"file": "/etc/enroll"}],
|
||||
reason="firewall_runtime",
|
||||
)
|
||||
runtime: Dict[str, Any] = {}
|
||||
for key, dest_name, mode in (
|
||||
("ipset_save", "ipset.save", "0600"),
|
||||
("iptables_v4_save", "iptables.v4", "0600"),
|
||||
("iptables_v6_save", "iptables.v6", "0600"),
|
||||
):
|
||||
src_rel = str(snap.get(key) or "").strip()
|
||||
if not src_rel:
|
||||
continue
|
||||
role_rel = _copy_artifact(
|
||||
bundle_dir,
|
||||
artifact_role,
|
||||
src_rel,
|
||||
role_files_dir,
|
||||
dst_prefix=file_prefix,
|
||||
)
|
||||
if not role_rel:
|
||||
self.notes.append(
|
||||
f"Firewall runtime artifact {src_rel!r} was referenced but not found."
|
||||
)
|
||||
continue
|
||||
dest = f"/etc/enroll/firewall/{dest_name}"
|
||||
self.add_managed_file(
|
||||
dest,
|
||||
user="root",
|
||||
group="root",
|
||||
mode=mode,
|
||||
source=_source_uri(self.module_name, role_rel),
|
||||
reason="firewall_runtime",
|
||||
)
|
||||
runtime[key] = dest
|
||||
ipset_sets = [
|
||||
str(x).strip() for x in (snap.get("ipset_sets") or []) if str(x).strip()
|
||||
]
|
||||
if ipset_sets:
|
||||
runtime["ipset_sets"] = ipset_sets
|
||||
if runtime:
|
||||
runtime.update(_firewall_runtime_commands(runtime))
|
||||
self.firewall_runtime.update(runtime)
|
||||
for note in snap.get("notes", []) or []:
|
||||
self.notes.append(str(note))
|
||||
|
||||
def add_container_images_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
for raw in snap.get("images", []) or []:
|
||||
|
|
@ -304,6 +413,238 @@ def _container_tag_cmd(engine: str, pull_ref: str, tag_ref: str) -> str:
|
|||
return f"{engine} tag {_shell_quote(pull_ref)} {_shell_quote(tag_ref)}"
|
||||
|
||||
|
||||
def _normalise_flatpak_item(
|
||||
item: Dict[str, Any],
|
||||
*,
|
||||
method: str,
|
||||
user: Optional[str] = None,
|
||||
home: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
out = dict(item)
|
||||
out["method"] = str(out.get("method") or method or "system").strip() or "system"
|
||||
if user and not out.get("user"):
|
||||
out["user"] = user
|
||||
if home and not out.get("home"):
|
||||
out["home"] = home
|
||||
ref = str(out.get("ref") or "").strip()
|
||||
if ref and not out.get("name"):
|
||||
out["name"] = ref.rsplit("/", 1)[-1]
|
||||
name = str(out.get("name") or out.get("app_id") or "").strip()
|
||||
if name:
|
||||
out["name"] = name
|
||||
remote = str(out.get("remote") or "").strip()
|
||||
if remote:
|
||||
out["remote"] = remote
|
||||
branch = str(out.get("branch") or out.get("origin") or "").strip()
|
||||
if branch:
|
||||
out["branch"] = branch
|
||||
if ref:
|
||||
out["ref"] = ref
|
||||
return out
|
||||
|
||||
|
||||
def _normalise_flatpak_remote(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = dict(item)
|
||||
name = str(out.get("name") or out.get("remote") or "").strip()
|
||||
url = str(out.get("url") or out.get("from_url") or "").strip()
|
||||
method = str(out.get("method") or out.get("scope") or "system").strip() or "system"
|
||||
if name:
|
||||
out["name"] = name
|
||||
if url:
|
||||
out["url"] = url
|
||||
out["method"] = "user" if method == "user" else "system"
|
||||
return out
|
||||
|
||||
|
||||
def _normalise_snap_item(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = dict(item)
|
||||
name = str(out.get("name") or "").strip()
|
||||
if name:
|
||||
out["name"] = name
|
||||
channel = str(out.get("tracking") or out.get("channel") or "").strip()
|
||||
if channel:
|
||||
out["channel"] = channel
|
||||
notes = [str(note).lower() for note in (out.get("notes") or [])]
|
||||
confinement = str(out.get("confinement") or "").strip().lower()
|
||||
out["classic"] = confinement == "classic" or any(
|
||||
"classic" in note for note in notes
|
||||
)
|
||||
out["devmode"] = any("devmode" in note or "dev mode" in note for note in notes)
|
||||
out["dangerous"] = any("dangerous" in note for note in notes)
|
||||
revision = str(out.get("revision") or "").strip()
|
||||
if revision and not channel:
|
||||
out["revision"] = revision
|
||||
return out
|
||||
|
||||
|
||||
def _flatpak_scope(item: Dict[str, Any]) -> str:
|
||||
return "--user" if str(item.get("method") or "system") == "user" else "--system"
|
||||
|
||||
|
||||
def _flatpak_home(item: Dict[str, Any]) -> Optional[str]:
|
||||
user = str(item.get("user") or "").strip()
|
||||
if not user:
|
||||
return None
|
||||
return str(item.get("home") or f"/home/{user}")
|
||||
|
||||
|
||||
def _flatpak_env(item: Dict[str, Any]) -> Dict[str, str]:
|
||||
home = _flatpak_home(item)
|
||||
if not home:
|
||||
return {}
|
||||
return {"HOME": home, "XDG_DATA_HOME": f"{home}/.local/share"}
|
||||
|
||||
|
||||
def _flatpak_remote_exists_cmd(item: Dict[str, Any]) -> str:
|
||||
return (
|
||||
f"flatpak {_flatpak_scope(item)} remote-list --columns=name "
|
||||
f"| grep -Fx -- {_shell_quote(item.get('name'))}"
|
||||
)
|
||||
|
||||
|
||||
def _flatpak_remote_add_cmd(item: Dict[str, Any]) -> str:
|
||||
return (
|
||||
f"flatpak {_flatpak_scope(item)} remote-add --if-not-exists "
|
||||
f"{_shell_quote(item.get('name'))} {_shell_quote(item.get('url'))}"
|
||||
)
|
||||
|
||||
|
||||
def _flatpak_ref(item: Dict[str, Any]) -> str:
|
||||
ref = str(item.get("ref") or "").strip()
|
||||
if ref:
|
||||
return ref
|
||||
return str(item.get("name") or "").strip()
|
||||
|
||||
|
||||
def _flatpak_exists_cmd(item: Dict[str, Any]) -> str:
|
||||
return f"flatpak {_flatpak_scope(item)} info {_shell_quote(_flatpak_ref(item))} >/dev/null 2>&1"
|
||||
|
||||
|
||||
def _flatpak_install_cmd(item: Dict[str, Any]) -> str:
|
||||
args = ["flatpak", _flatpak_scope(item), "install", "-y"]
|
||||
remote = str(item.get("remote") or "").strip()
|
||||
if remote:
|
||||
args.append(remote)
|
||||
args.append(_flatpak_ref(item))
|
||||
return " ".join(_shell_quote(arg) for arg in args)
|
||||
|
||||
|
||||
def _prepare_flatpak_remote(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = dict(item)
|
||||
method = str(out.get("method") or "system")
|
||||
user = str(out.get("user") or "")
|
||||
name = str(out.get("name") or "")
|
||||
out["state_id"] = _state_id("flatpak_remote", f"{method}:{user}:{name}")
|
||||
out["add_cmd"] = _flatpak_remote_add_cmd(out)
|
||||
out["exists_cmd"] = _flatpak_remote_exists_cmd(out)
|
||||
out["env"] = _flatpak_env(out)
|
||||
return out
|
||||
|
||||
|
||||
def _prepare_flatpak_item(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = dict(item)
|
||||
method = str(out.get("method") or "system")
|
||||
user = str(out.get("user") or "")
|
||||
ref = _flatpak_ref(out)
|
||||
out["state_id"] = _state_id("flatpak", f"{method}:{user}:{ref}")
|
||||
out["install_cmd"] = _flatpak_install_cmd(out)
|
||||
out["exists_cmd"] = _flatpak_exists_cmd(out)
|
||||
out["env"] = _flatpak_env(out)
|
||||
return out
|
||||
|
||||
|
||||
def _snap_exists_cmd(item: Dict[str, Any]) -> str:
|
||||
return f"snap list {_shell_quote(item.get('name'))} >/dev/null 2>&1"
|
||||
|
||||
|
||||
def _snap_install_cmd(item: Dict[str, Any]) -> str:
|
||||
args = ["snap", "install", str(item.get("name") or "")]
|
||||
channel = str(item.get("channel") or "").strip()
|
||||
revision = str(item.get("revision") or "").strip()
|
||||
if channel:
|
||||
args.append(f"--channel={channel}")
|
||||
elif revision:
|
||||
args.append(f"--revision={revision}")
|
||||
if item.get("classic"):
|
||||
args.append("--classic")
|
||||
if item.get("devmode"):
|
||||
args.append("--devmode")
|
||||
if item.get("dangerous"):
|
||||
args.append("--dangerous")
|
||||
return " ".join(_shell_quote(arg) for arg in args if str(arg))
|
||||
|
||||
|
||||
def _prepare_snap_item(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = dict(item)
|
||||
name = str(out.get("name") or "")
|
||||
out["state_id"] = _state_id("snap", name)
|
||||
out["install_cmd"] = _snap_install_cmd(out)
|
||||
out["exists_cmd"] = _snap_exists_cmd(out)
|
||||
return out
|
||||
|
||||
|
||||
def _firewall_ipset_restore_cmd(path: str, sets: List[str]) -> str:
|
||||
flush_parts = [f"ipset flush {_shell_quote(name)} || true" for name in sets]
|
||||
flush = "; ".join(flush_parts)
|
||||
restore = f"ipset restore -exist < {_shell_quote(path)}"
|
||||
if flush:
|
||||
return f"/bin/sh -c {_shell_quote(flush + '; ' + restore)}"
|
||||
return f"/bin/sh -c {_shell_quote(restore)}"
|
||||
|
||||
|
||||
def _firewall_runtime_commands(runtime: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {}
|
||||
ipset_path = str(runtime.get("ipset_save") or "")
|
||||
if ipset_path:
|
||||
sets = [str(x) for x in (runtime.get("ipset_sets") or []) if str(x)]
|
||||
out["ipset_restore_cmd"] = _firewall_ipset_restore_cmd(ipset_path, sets)
|
||||
ipt4_path = str(runtime.get("iptables_v4_save") or "")
|
||||
if ipt4_path:
|
||||
out["iptables_v4_restore_cmd"] = f"iptables-restore {_shell_quote(ipt4_path)}"
|
||||
ipt6_path = str(runtime.get("iptables_v6_save") or "")
|
||||
if ipt6_path:
|
||||
out["iptables_v6_restore_cmd"] = f"ip6tables-restore {_shell_quote(ipt6_path)}"
|
||||
return out
|
||||
|
||||
|
||||
def _append_firewall_runtime_states(lines: List[str], runtime: Dict[str, Any]) -> None:
|
||||
specs = [
|
||||
(
|
||||
"ipset",
|
||||
"ipset_save",
|
||||
"ipset_restore_cmd",
|
||||
"enroll_firewall_runtime_ipset_restore",
|
||||
),
|
||||
(
|
||||
"iptables_v4",
|
||||
"iptables_v4_save",
|
||||
"iptables_v4_restore_cmd",
|
||||
"enroll_firewall_runtime_iptables_v4_restore",
|
||||
),
|
||||
(
|
||||
"iptables_v6",
|
||||
"iptables_v6_save",
|
||||
"iptables_v6_restore_cmd",
|
||||
"enroll_firewall_runtime_iptables_v6_restore",
|
||||
),
|
||||
]
|
||||
for _family, path_key, cmd_key, state_id in specs:
|
||||
path = str(runtime.get(path_key) or "")
|
||||
command = str(runtime.get(cmd_key) or "")
|
||||
if not path or not command:
|
||||
continue
|
||||
lines.extend(
|
||||
[
|
||||
f"{state_id}:",
|
||||
" cmd.run:",
|
||||
f" - name: {_yaml_quote(command)}",
|
||||
" - onchanges:",
|
||||
f" - file: {_yaml_quote(path)}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _clean_gecos_part(value: Any) -> Optional[str]:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
|
@ -402,23 +743,6 @@ def _node_sls_basename(fqdn: str) -> str:
|
|||
return f"{name}_{digest}"
|
||||
|
||||
|
||||
def _add_flatpak_snap_notes(roles: Dict[str, Any], out: Dict[str, SaltRole]) -> None:
|
||||
flatpak = roles.get("flatpak") or {}
|
||||
if isinstance(flatpak, dict) and (
|
||||
flatpak.get("system_flatpaks") or flatpak.get("remotes")
|
||||
):
|
||||
srole = out.setdefault("flatpak", SaltRole("flatpak"))
|
||||
srole.notes.append(
|
||||
"Flatpak resources were detected but are not rendered as native Salt states."
|
||||
)
|
||||
snap = roles.get("snap") or {}
|
||||
if isinstance(snap, dict) and snap.get("system_snaps"):
|
||||
srole = out.setdefault("snap", SaltRole("snap"))
|
||||
srole.notes.append(
|
||||
"Snap resources were detected but are not rendered as native Salt states."
|
||||
)
|
||||
|
||||
|
||||
def _collect_salt_roles(
|
||||
state: Dict[str, Any],
|
||||
bundle_dir: str,
|
||||
|
|
@ -563,15 +887,37 @@ def _collect_salt_roles(
|
|||
packages = [
|
||||
str(p).strip() for p in (fw.get("packages") or []) if str(p).strip()
|
||||
]
|
||||
if has_fw or packages:
|
||||
srole = ensure_role(str(fw.get("role_name") or "firewall_runtime"))
|
||||
srole.packages.update(packages)
|
||||
if has_fw or packages or fw.get("notes"):
|
||||
if has_fw:
|
||||
srole.notes.append(
|
||||
"Live firewall runtime snapshots were detected but are not rendered as Salt states."
|
||||
runtime_role = ensure_role("enroll_runtime")
|
||||
runtime_role.add_managed_dir(
|
||||
"/etc/enroll",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="0750",
|
||||
reason="enroll_runtime",
|
||||
)
|
||||
role_name = str(fw.get("role_name") or "firewall_runtime")
|
||||
srole = ensure_role(role_name)
|
||||
srole.add_firewall_runtime_snapshot(
|
||||
fw,
|
||||
bundle_dir=bundle_dir,
|
||||
artifact_role=role_name,
|
||||
role_files_dir=states_dir / "roles" / srole.module_name / "files",
|
||||
file_prefix=node_file_prefix,
|
||||
)
|
||||
|
||||
_add_flatpak_snap_notes(roles, out)
|
||||
flatpak = roles.get("flatpak") or {}
|
||||
if isinstance(flatpak, dict) and (
|
||||
flatpak.get("system_flatpaks") or flatpak.get("remotes") or flatpak.get("notes")
|
||||
):
|
||||
srole = ensure_role(str(flatpak.get("role_name") or "flatpak"))
|
||||
srole.add_flatpak_snapshot(flatpak)
|
||||
|
||||
snap = roles.get("snap") or {}
|
||||
if isinstance(snap, dict) and (snap.get("system_snaps") or snap.get("notes")):
|
||||
srole = ensure_role(str(snap.get("role_name") or "snap"))
|
||||
srole.add_snap_snapshot(snap)
|
||||
|
||||
salt_roles = sorted(out.values(), key=lambda r: role_order_key(r.role_name))
|
||||
resolve_catalog_conflicts(salt_roles)
|
||||
|
|
@ -653,9 +999,15 @@ def _render_static_role(srole: SaltRole) -> str:
|
|||
f" - group: {_yaml_quote(attrs.get('group') or 'root')}",
|
||||
f" - mode: {_yaml_quote(str(attrs.get('mode') or '0755'))}",
|
||||
" - makedirs: true",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if attrs.get("require"):
|
||||
lines.append(" - require:")
|
||||
for req in attrs.get("require") or []:
|
||||
if isinstance(req, dict):
|
||||
for req_kind, req_name in req.items():
|
||||
lines.append(f" - {req_kind}: {_yaml_quote(req_name)}")
|
||||
lines.append("")
|
||||
|
||||
for path, attrs in sorted(srole.files.items()):
|
||||
lines.extend(
|
||||
|
|
@ -700,6 +1052,101 @@ def _render_static_role(srole: SaltRole) -> str:
|
|||
]
|
||||
)
|
||||
|
||||
flatpak_remote_state_ids: Dict[Tuple[str, str, str], str] = {}
|
||||
for remote in srole.flatpak_remotes:
|
||||
name = str(remote.get("name") or "").strip()
|
||||
url = str(remote.get("url") or "").strip()
|
||||
if not name or not url:
|
||||
continue
|
||||
state_id = str(
|
||||
remote.get("state_id")
|
||||
or _state_id("flatpak_remote", name, role=srole.module_name)
|
||||
)
|
||||
key = (
|
||||
str(remote.get("method") or "system"),
|
||||
str(remote.get("user") or ""),
|
||||
name,
|
||||
)
|
||||
flatpak_remote_state_ids[key] = state_id
|
||||
lines.extend(
|
||||
[
|
||||
f"{state_id}:",
|
||||
" cmd.run:",
|
||||
f" - name: {_yaml_quote(remote.get('add_cmd') or _flatpak_remote_add_cmd(remote))}",
|
||||
f" - unless: {_yaml_quote(remote.get('exists_cmd') or _flatpak_remote_exists_cmd(remote))}",
|
||||
]
|
||||
)
|
||||
remote_user = str(remote.get("user") or "")
|
||||
if remote_user:
|
||||
lines.append(f" - runas: {_yaml_quote(remote_user)}")
|
||||
env = remote.get("env") or {}
|
||||
if env:
|
||||
lines.append(" - env:")
|
||||
for key_name, value in sorted(env.items()):
|
||||
lines.append(f" - {key_name}: {_yaml_quote(value)}")
|
||||
if remote_user and remote_user in srole.users:
|
||||
lines.extend(
|
||||
[
|
||||
" - require:",
|
||||
f" - user: {_state_id('user', remote_user, role=srole.module_name)}",
|
||||
]
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
for app in srole.flatpaks:
|
||||
ref = _flatpak_ref(app)
|
||||
if not ref:
|
||||
continue
|
||||
state_id = str(
|
||||
app.get("state_id") or _state_id("flatpak", ref, role=srole.module_name)
|
||||
)
|
||||
method = str(app.get("method") or "system")
|
||||
user = str(app.get("user") or "")
|
||||
remote_name = str(app.get("remote") or "")
|
||||
require_entries: List[Tuple[str, str]] = []
|
||||
if user and user in srole.users:
|
||||
require_entries.append(
|
||||
("user", _state_id("user", user, role=srole.module_name))
|
||||
)
|
||||
if remote_name:
|
||||
remote_state_id = flatpak_remote_state_ids.get((method, user, remote_name))
|
||||
if remote_state_id:
|
||||
require_entries.append(("cmd", remote_state_id))
|
||||
lines.extend(
|
||||
[
|
||||
f"{state_id}:",
|
||||
" cmd.run:",
|
||||
f" - name: {_yaml_quote(app.get('install_cmd') or _flatpak_install_cmd(app))}",
|
||||
f" - unless: {_yaml_quote(app.get('exists_cmd') or _flatpak_exists_cmd(app))}",
|
||||
]
|
||||
)
|
||||
if app.get("user"):
|
||||
lines.append(f" - runas: {_yaml_quote(app.get('user'))}")
|
||||
env = app.get("env") or {}
|
||||
if env:
|
||||
lines.append(" - env:")
|
||||
for key_name, value in sorted(env.items()):
|
||||
lines.append(f" - {key_name}: {_yaml_quote(value)}")
|
||||
if require_entries:
|
||||
lines.append(" - require:")
|
||||
for req_kind, req_name in require_entries:
|
||||
lines.append(f" - {req_kind}: {req_name}")
|
||||
lines.append("")
|
||||
|
||||
for snap in srole.snaps:
|
||||
name = str(snap.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
lines.extend(
|
||||
[
|
||||
f"{snap.get('state_id') or _state_id('snap', name, role=srole.module_name)}:",
|
||||
" cmd.run:",
|
||||
f" - name: {_yaml_quote(snap.get('install_cmd') or _snap_install_cmd(snap))}",
|
||||
f" - unless: {_yaml_quote(snap.get('exists_cmd') or _snap_exists_cmd(snap))}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
for idx, image in enumerate(srole.container_images, start=1):
|
||||
engine = str(image.get("engine") or "").strip()
|
||||
pull_ref = str(image.get("pull_ref") or "").strip()
|
||||
|
|
@ -758,6 +1205,9 @@ def _render_static_role(srole: SaltRole) -> str:
|
|||
]
|
||||
)
|
||||
|
||||
if srole.firewall_runtime:
|
||||
_append_firewall_runtime_states(lines, srole.firewall_runtime)
|
||||
|
||||
if "/etc/sysctl.d/99-enroll.conf" in srole.files:
|
||||
lines.extend(
|
||||
[
|
||||
|
|
@ -815,6 +1265,7 @@ def _role_pillar_values(srole: SaltRole) -> Dict[str, Any]:
|
|||
"group": attrs.get("group") or "root",
|
||||
"mode": str(attrs.get("mode") or "0755"),
|
||||
"makedirs": True,
|
||||
**({"require": attrs.get("require")} if attrs.get("require") else {}),
|
||||
}
|
||||
for path, attrs in sorted(srole.dirs.items())
|
||||
}
|
||||
|
|
@ -853,8 +1304,16 @@ def _role_pillar_values(srole: SaltRole) -> Dict[str, Any]:
|
|||
}
|
||||
if "/etc/sysctl.d/99-enroll.conf" in srole.files:
|
||||
data["sysctl_apply"] = True
|
||||
if srole.flatpak_remotes:
|
||||
data["flatpak_remotes"] = list(srole.flatpak_remotes)
|
||||
if srole.flatpaks:
|
||||
data["flatpaks"] = list(srole.flatpaks)
|
||||
if srole.snaps:
|
||||
data["snaps"] = list(srole.snaps)
|
||||
if srole.container_images:
|
||||
data["container_images"] = list(srole.container_images)
|
||||
if srole.firewall_runtime:
|
||||
data["firewall_runtime"] = dict(srole.firewall_runtime)
|
||||
if srole.notes:
|
||||
data["notes"] = list(srole.notes)
|
||||
return data
|
||||
|
|
@ -915,6 +1374,14 @@ def _render_pillar_role(srole: SaltRole) -> str:
|
|||
" - group: {{ attrs.get('group', 'root')|yaml_dquote }}",
|
||||
" - mode: {{ attrs.get('mode', '0755')|string|yaml_dquote }}",
|
||||
" - makedirs: {{ attrs.get('makedirs', True)|yaml_encode }}",
|
||||
"{% if attrs.get('require') %}",
|
||||
" - require:",
|
||||
"{% for req in attrs.get('require', []) %}",
|
||||
"{% for req_kind, req_name in req.items() %}",
|
||||
" - {{ req_kind }}: {{ req_name|yaml_dquote }}",
|
||||
"{% endfor %}",
|
||||
"{% endfor %}",
|
||||
"{% endif %}",
|
||||
"{% endfor %}",
|
||||
"",
|
||||
"{% for path, attrs in role.get('files', {}).items() %}",
|
||||
|
|
@ -948,6 +1415,45 @@ def _render_pillar_role(srole: SaltRole) -> str:
|
|||
" - enable: {{ svc.get('enable', False)|yaml_encode }}",
|
||||
"{% endfor %}",
|
||||
"",
|
||||
"{% for remote in role.get('flatpak_remotes', []) %}",
|
||||
"{{ remote.get('state_id') }}:",
|
||||
" cmd.run:",
|
||||
" - name: {{ remote.get('add_cmd')|yaml_dquote }}",
|
||||
" - unless: {{ remote.get('exists_cmd')|yaml_dquote }}",
|
||||
"{% if remote.get('user') %}",
|
||||
" - runas: {{ remote.get('user')|yaml_dquote }}",
|
||||
"{% endif %}",
|
||||
"{% if remote.get('env') %}",
|
||||
" - env:",
|
||||
"{% for env_key, env_value in remote.get('env', {}).items() %}",
|
||||
" - {{ env_key }}: {{ env_value|yaml_dquote }}",
|
||||
"{% endfor %}",
|
||||
"{% endif %}",
|
||||
"{% endfor %}",
|
||||
"",
|
||||
"{% for app in role.get('flatpaks', []) %}",
|
||||
"{{ app.get('state_id') }}:",
|
||||
" cmd.run:",
|
||||
" - name: {{ app.get('install_cmd')|yaml_dquote }}",
|
||||
" - unless: {{ app.get('exists_cmd')|yaml_dquote }}",
|
||||
"{% if app.get('user') %}",
|
||||
" - runas: {{ app.get('user')|yaml_dquote }}",
|
||||
"{% endif %}",
|
||||
"{% if app.get('env') %}",
|
||||
" - env:",
|
||||
"{% for env_key, env_value in app.get('env', {}).items() %}",
|
||||
" - {{ env_key }}: {{ env_value|yaml_dquote }}",
|
||||
"{% endfor %}",
|
||||
"{% endif %}",
|
||||
"{% endfor %}",
|
||||
"",
|
||||
"{% for snap in role.get('snaps', []) %}",
|
||||
"{{ snap.get('state_id') }}:",
|
||||
" cmd.run:",
|
||||
" - name: {{ snap.get('install_cmd')|yaml_dquote }}",
|
||||
" - unless: {{ snap.get('exists_cmd')|yaml_dquote }}",
|
||||
"{% endfor %}",
|
||||
"",
|
||||
"{% for image in role.get('container_images', []) %}",
|
||||
"{% if image.get('engine') == 'docker' and image.get('pull_ref') %}",
|
||||
f"enroll_docker_pull_{role_key}_{{{{ loop.index }}}}:",
|
||||
|
|
@ -980,6 +1486,31 @@ def _render_pillar_role(srole: SaltRole) -> str:
|
|||
"{% endif %}",
|
||||
"{% endfor %}",
|
||||
"",
|
||||
"{% set firewall_runtime = role.get('firewall_runtime', {}) %}",
|
||||
"{% if firewall_runtime.get('ipset_restore_cmd') %}",
|
||||
"enroll_firewall_runtime_ipset_restore:",
|
||||
" cmd.run:",
|
||||
" - name: {{ firewall_runtime.get('ipset_restore_cmd')|yaml_dquote }}",
|
||||
" - onchanges:",
|
||||
" - file: {{ firewall_runtime.get('ipset_save')|yaml_dquote }}",
|
||||
"{% endif %}",
|
||||
"",
|
||||
"{% if firewall_runtime.get('iptables_v4_restore_cmd') %}",
|
||||
"enroll_firewall_runtime_iptables_v4_restore:",
|
||||
" cmd.run:",
|
||||
" - name: {{ firewall_runtime.get('iptables_v4_restore_cmd')|yaml_dquote }}",
|
||||
" - onchanges:",
|
||||
" - file: {{ firewall_runtime.get('iptables_v4_save')|yaml_dquote }}",
|
||||
"{% endif %}",
|
||||
"",
|
||||
"{% if firewall_runtime.get('iptables_v6_restore_cmd') %}",
|
||||
"enroll_firewall_runtime_iptables_v6_restore:",
|
||||
" cmd.run:",
|
||||
" - name: {{ firewall_runtime.get('iptables_v6_restore_cmd')|yaml_dquote }}",
|
||||
" - onchanges:",
|
||||
" - file: {{ firewall_runtime.get('iptables_v6_save')|yaml_dquote }}",
|
||||
"{% endif %}",
|
||||
"",
|
||||
"{% if role.get('sysctl_apply') and '/etc/sysctl.d/99-enroll.conf' in role.get('files', {}) %}",
|
||||
f"enroll_apply_sysctl_{role_key}:",
|
||||
" cmd.run:",
|
||||
|
|
@ -1164,10 +1695,12 @@ This Salt target reuses the existing harvest state without changing harvesting b
|
|||
- `/etc/sysctl.d/99-enroll.conf` plus an `onchanges` sysctl apply command when present.
|
||||
- Docker images by digest using guarded `docker pull` / `docker tag` command states.
|
||||
- Podman images by digest using guarded `podman pull` / `podman tag` command states.
|
||||
- Flatpak remotes and applications using guarded `flatpak remote-add` / `flatpak install` command states.
|
||||
- Snap applications using guarded `snap install` command states.
|
||||
- Live firewall runtime snapshots using staged `/etc/enroll/firewall/*` files and guarded restore command states.
|
||||
|
||||
## Current limitations
|
||||
|
||||
- Flatpak, Snap, and live firewall runtime snapshots are listed as notes when present rather than rendered as Salt states.
|
||||
- JinjaTurtle templating is applied on a best-effort basis for file formats it recognises; unrecognised files are copied literally.
|
||||
- Review generated resources before applying them broadly across unlike hosts.
|
||||
|
||||
|
|
|
|||
Reference in a new issue