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