Add sysctl detection

This commit is contained in:
Miguel Jacq 2026-06-16 14:23:44 +10:00
parent 3c19ae54b2
commit 9546e1b8ed
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
11 changed files with 544 additions and 2 deletions

View file

@ -3,6 +3,8 @@
* Add support for detecting flatpaks and snaps * Add support for detecting flatpaks and snaps
* BREAKING CHANGE: Group all package and systemd-unit roles into Debian Section/RPM Group roles by default, including managed config files and unit state. This mode is not used if `--fqdn` or `--no-common-roles` is set, in which case, the traditional behaviour of preserving one role per package/unit is used instead. * BREAKING CHANGE: Group all package and systemd-unit roles into Debian Section/RPM Group roles by default, including managed config files and unit state. This mode is not used if `--fqdn` or `--no-common-roles` is set, in which case, the traditional behaviour of preserving one role per package/unit is used instead.
* BREAKING CHANGE: Only capture user-specific .bashrc style files when using `--dangerous` mode, in case they contain sensitive env vars. * BREAKING CHANGE: Only capture user-specific .bashrc style files when using `--dangerous` mode, in case they contain sensitive env vars.
* Detect active sysctl parameters and write them to a `/etc/sysctl.d/99-enroll.conf` file
* Use `no_log` on systemd unit interrogations to suppress potential sensitive output when applying Ansible
# 0.6.0 # 0.6.0

View file

@ -13,6 +13,7 @@
- Defensively excludes likely secrets (path denylist + content sniff + size caps). - Defensively excludes likely secrets (path denylist + content sniff + size caps).
- Captures non-system users and their SSH public keys. In `--dangerous` mode, it also auto-harvests common shell dotfiles such as `.bashrc`, `.profile`, `.bash_logout`, and `.bash_aliases` when appropriate. - Captures non-system users and their SSH public keys. In `--dangerous` mode, it also auto-harvests common shell dotfiles such as `.bashrc`, `.profile`, `.bash_logout`, and `.bash_aliases` when appropriate.
- Captures miscellaneous `/etc` files it can't attribute to a package and installs them in an `etc_custom` role. - Captures miscellaneous `/etc` files it can't attribute to a package and installs them in an `etc_custom` role.
- When running as root/sudo, captures live writable sysctl state into a `sysctl` role that manages `/etc/sysctl.d/99-enroll.conf`.
- Captures live ipset and iptables runtime state into a fallback `firewall_runtime` role, when active ipsets/iptables rules are present *and* no corresponding persistent ipset/iptables *files* were found. - Captures live ipset and iptables runtime state into a fallback `firewall_runtime` role, when active ipsets/iptables rules are present *and* no corresponding persistent ipset/iptables *files* were found.
- Captures symlinks in common applications that rely on them, e.g apache2/nginx 'sites-enabled' - Captures symlinks in common applications that rely on them, e.g apache2/nginx 'sites-enabled'
- Ditto for /usr/local/bin (for non-binary files) and /usr/local/etc - Ditto for /usr/local/bin (for non-binary files) and /usr/local/etc
@ -73,6 +74,7 @@ Harvest state about a host and write a harvest bundle.
- In `--dangerous` mode: common per-user shell dotfiles that are likely to represent deliberate account customisation - In `--dangerous` mode: common per-user shell dotfiles that are likely to represent deliberate account customisation
- Misc `/etc` that can't be attributed to a package (`etc_custom` role) - Misc `/etc` that can't be attributed to a package (`etc_custom` role)
- Static firewall config files such as nftables, UFW, firewalld, `/etc/iptables/rules.v4`, `/etc/iptables/rules.v6`, and `/etc/ipset*` - Static firewall config files such as nftables, UFW, firewalld, `/etc/iptables/rules.v4`, `/etc/iptables/rules.v6`, and `/etc/ipset*`
- Live writable sysctl state via `sysctl -a`, emitted as `/etc/sysctl.d/99-enroll.conf` at manifest time when running as root/sudo (`sysctl` role)
- Live kernel ipset/iptables state via `ipset save`, `iptables-save`, and `ip6tables-save` as a fallback, but only when the corresponding persistent config was not found (`firewall_runtime` role at manifest time) - Live kernel ipset/iptables state via `ipset save`, `iptables-save`, and `ip6tables-save` as a fallback, but only when the corresponding persistent config was not found (`firewall_runtime` role at manifest time)
- Optional user-specified extra files/dirs via `--include-path` (emitted as an `extra_paths` role at manifest time) - Optional user-specified extra files/dirs via `--include-path` (emitted as an `extra_paths` role at manifest time)

View file

@ -303,6 +303,12 @@ def _iter_managed_files(state: Dict[str, Any]) -> Iterable[Tuple[str, Dict[str,
for mf in ac.get("managed_files", []) or []: for mf in ac.get("managed_files", []) or []:
yield str(ac_role), mf yield str(ac_role), mf
# sysctl
sc = _roles(state).get("sysctl") or {}
sc_role = sc.get("role_name") or "sysctl"
for mf in sc.get("managed_files", []) or []:
yield str(sc_role), mf
# etc_custom # etc_custom
ec = _roles(state).get("etc_custom") or {} ec = _roles(state).get("etc_custom") or {}
ec_role = ec.get("role_name") or "etc_custom" ec_role = ec.get("role_name") or "etc_custom"

View file

@ -383,6 +383,7 @@ def explain_state(
for rname in [ for rname in [
"apt_config", "apt_config",
"dnf_config", "dnf_config",
"sysctl",
"etc_custom", "etc_custom",
"usr_local_custom", "usr_local_custom",
"extra_paths", "extra_paths",
@ -435,6 +436,7 @@ def explain_state(
for rname in [ for rname in [
"apt_config", "apt_config",
"dnf_config", "dnf_config",
"sysctl",
"etc_custom", "etc_custom",
"usr_local_custom", "usr_local_custom",
"extra_paths", "extra_paths",

View file

@ -181,6 +181,14 @@ class FirewallRuntimeSnapshot:
notes: List[str] = field(default_factory=list) notes: List[str] = field(default_factory=list)
@dataclass
class SysctlSnapshot:
role_name: str
managed_files: List[ManagedFile] = field(default_factory=list)
parameters: Dict[str, str] = field(default_factory=dict)
notes: List[str] = field(default_factory=list)
ALLOWED_UNOWNED_EXTS = { ALLOWED_UNOWNED_EXTS = {
".cfg", ".cfg",
".cnf", ".cnf",
@ -1049,6 +1057,7 @@ _FIREWALL_CAPTURE_COMMANDS: Dict[str, Tuple[str, ...]] = {
"ipset_save": ("ipset", "save"), "ipset_save": ("ipset", "save"),
"iptables_v4_save": ("iptables-save",), "iptables_v4_save": ("iptables-save",),
"iptables_v6_save": ("ip6tables-save",), "iptables_v6_save": ("ip6tables-save",),
"sysctl_all": ("sysctl", "-a"),
} }
@ -1104,6 +1113,165 @@ def _write_generated_artifact(
f.write(content) f.write(content)
_SYSCTL_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
_SYSCTL_GENERATED_DEST = "/etc/sysctl.d/99-enroll.conf"
_SYSCTL_GENERATED_SRC_REL = "sysctl/99-enroll.conf"
# Writable-looking action/identity keys that are poor candidates for persistent
# config. This avoids generating a file that tries to replay one-shot triggers or
# host identity that should be managed elsewhere (e.g. /etc/hostname).
_SYSCTL_VOLATILE_KEYS = {
"kernel.domainname",
"kernel.hostname",
"kernel.ns_last_pid",
"net.ipv4.route.flush",
"net.ipv6.route.flush",
"vm.compact_memory",
"vm.drop_caches",
"vm.stat_refresh",
}
def _sysctl_proc_path(key: str) -> str:
return "/proc/sys/" + key.replace(".", "/")
def _sysctl_key_is_persistable(key: str) -> tuple[bool, str]:
if not key or not _SYSCTL_KEY_RE.fullmatch(key):
return False, "invalid key"
if key in _SYSCTL_VOLATILE_KEYS:
return False, "volatile/action key"
proc_path = _sysctl_proc_path(key)
try:
st = os.stat(proc_path)
except OSError:
return False, "no /proc/sys entry"
if not stat.S_ISREG(st.st_mode):
return False, "not a regular /proc/sys entry"
if (stat.S_IMODE(st.st_mode) & 0o222) == 0:
return False, "read-only /proc/sys entry"
return True, ""
def _parse_sysctl_a_output(
text: str,
*,
require_persistable: bool = True,
) -> tuple[Dict[str, str], Dict[str, int]]:
"""Parse `sysctl -a` output into persistable key/value pairs.
`sysctl -a` includes read-only, write-only, multiline, action-like, and
host-identity values. Persisting those can create noisy or failing Ansible
runs, so the default parser keeps only single-line writable-looking keys.
"""
out: Dict[str, str] = {}
skipped: Dict[str, int] = {
"malformed": 0,
"empty_value": 0,
"non_persistable": 0,
"duplicate": 0,
}
for raw in (text or "").splitlines():
line = raw.strip()
if not line:
continue
if " = " in line:
key, value = line.split(" = ", 1)
elif "=" in line:
key, value = line.split("=", 1)
else:
skipped["malformed"] += 1
continue
key = key.strip()
value = value.strip()
if not key:
skipped["malformed"] += 1
continue
if value == "":
skipped["empty_value"] += 1
continue
if key in out:
skipped["duplicate"] += 1
continue
if require_persistable:
ok, _reason = _sysctl_key_is_persistable(key)
if not ok:
skipped["non_persistable"] += 1
continue
out[key] = value
return dict(sorted(out.items())), skipped
def _render_sysctl_conf(parameters: Dict[str, str], notes: List[str]) -> str:
lines = [
"# Generated by Enroll from live sysctl state.",
"# Review before applying broadly; runtime sysctl state can be host/kernel-specific.",
]
for note in notes:
lines.append(f"# {note}")
lines.append("")
for key, value in sorted((parameters or {}).items()):
safe_value = str(value).replace("\n", " ").strip()
lines.append(f"{key} = {safe_value}")
lines.append("")
return "\n".join(lines)
def _collect_sysctl_snapshot(bundle_dir: str) -> SysctlSnapshot:
role_name = "sysctl"
notes: List[str] = []
managed_files: List[ManagedFile] = []
out, err = _run_capture_command("sysctl_all", timeout=20)
if err:
notes.append(err)
return SysctlSnapshot(role_name=role_name, notes=notes)
parameters, skipped = _parse_sysctl_a_output(out or "")
if not parameters:
notes.append("No persistable live sysctl parameters were detected.")
return SysctlSnapshot(role_name=role_name, parameters=parameters, notes=notes)
notes.append(f"Captured {len(parameters)} live writable sysctl parameter(s).")
skipped_total = sum(skipped.values())
if skipped_total:
details = ", ".join(f"{k}={v}" for k, v in sorted(skipped.items()) if v)
notes.append(
"Skipped "
f"{skipped_total} sysctl entr{'y' if skipped_total == 1 else 'ies'} "
f"that were not suitable for persistence ({details})."
)
_write_generated_artifact(
bundle_dir,
role_name,
_SYSCTL_GENERATED_SRC_REL,
_render_sysctl_conf(parameters, notes),
)
managed_files.append(
ManagedFile(
path=_SYSCTL_GENERATED_DEST,
src_rel=_SYSCTL_GENERATED_SRC_REL,
owner="root",
group="root",
mode="0644",
reason="system_sysctl",
)
)
return SysctlSnapshot(
role_name=role_name,
managed_files=managed_files,
parameters=parameters,
notes=notes,
)
def _ipset_save_has_state(text: str) -> bool: def _ipset_save_has_state(text: str) -> bool:
for raw in (text or "").splitlines(): for raw in (text or "").splitlines():
line = raw.strip() line = raw.strip()
@ -1300,13 +1468,20 @@ def harvest(
_PERSISTENT_IPTABLES_V6_GLOBS _PERSISTENT_IPTABLES_V6_GLOBS
) )
if hasattr(os, "geteuid") and os.geteuid() != 0: running_as_root = not hasattr(os, "geteuid") or os.geteuid() == 0
if not running_as_root:
firewall_runtime_snapshot = FirewallRuntimeSnapshot( firewall_runtime_snapshot = FirewallRuntimeSnapshot(
role_name="firewall_runtime", role_name="firewall_runtime",
notes=[ notes=[
"Live ipset/iptables runtime capture skipped because harvest is not running as root." "Live ipset/iptables runtime capture skipped because harvest is not running as root."
], ],
) )
sysctl_snapshot = SysctlSnapshot(
role_name="sysctl",
notes=[
"Live sysctl runtime capture skipped because harvest is not running as root."
],
)
else: else:
firewall_runtime_snapshot = _collect_firewall_runtime_snapshot( firewall_runtime_snapshot = _collect_firewall_runtime_snapshot(
bundle_dir, bundle_dir,
@ -1314,6 +1489,12 @@ def harvest(
persistent_iptables_v4_files=persistent_iptables_v4_files, persistent_iptables_v4_files=persistent_iptables_v4_files,
persistent_iptables_v6_files=persistent_iptables_v6_files, persistent_iptables_v6_files=persistent_iptables_v6_files,
) )
sysctl_snapshot = _collect_sysctl_snapshot(bundle_dir)
# The generated sysctl role owns /etc/sysctl.d/99-enroll.conf; do not also
# capture an existing file at that path into etc_custom/package roles.
for mf in sysctl_snapshot.managed_files:
captured_global.add(mf.path)
def _pick_installed(cands: List[str]) -> Optional[str]: def _pick_installed(cands: List[str]) -> Optional[str]:
for c in cands: for c in cands:
@ -2682,6 +2863,7 @@ def harvest(
"apt_config": asdict(apt_config_snapshot), "apt_config": asdict(apt_config_snapshot),
"dnf_config": asdict(dnf_config_snapshot), "dnf_config": asdict(dnf_config_snapshot),
"firewall_runtime": asdict(firewall_runtime_snapshot), "firewall_runtime": asdict(firewall_runtime_snapshot),
"sysctl": asdict(sysctl_snapshot),
"etc_custom": asdict(etc_custom_snapshot), "etc_custom": asdict(etc_custom_snapshot),
"usr_local_custom": asdict(usr_local_custom_snapshot), "usr_local_custom": asdict(usr_local_custom_snapshot),
"extra_paths": asdict(extra_paths_snapshot), "extra_paths": asdict(extra_paths_snapshot),

View file

@ -728,6 +728,7 @@ def _render_grouped_systemd_tasks(var_prefix: str) -> str:
return f"""- name: Probe whether grouped systemd units exist and are manageable return f"""- name: Probe whether grouped systemd units exist and are manageable
ansible.builtin.systemd: ansible.builtin.systemd:
name: "{{{{ item.name }}}}" name: "{{{{ item.name }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
check_mode: true check_mode: true
loop: "{{{{ {var_prefix}_systemd_units | default([]) }}}}" loop: "{{{{ {var_prefix}_systemd_units | default([]) }}}}"
register: _enroll_unit_probes register: _enroll_unit_probes
@ -739,6 +740,7 @@ def _render_grouped_systemd_tasks(var_prefix: str) -> str:
ansible.builtin.systemd: ansible.builtin.systemd:
name: "{{{{ item.item.name }}}}" name: "{{{{ item.item.name }}}}"
enabled: "{{{{ item.item.enabled | bool }}}}" enabled: "{{{{ item.item.enabled | bool }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
loop: "{{{{ _enroll_unit_probes.results | default([]) }}}}" loop: "{{{{ _enroll_unit_probes.results | default([]) }}}}"
when: when:
- item.item.manage | default(false) - item.item.manage | default(false)
@ -748,6 +750,7 @@ def _render_grouped_systemd_tasks(var_prefix: str) -> str:
ansible.builtin.systemd: ansible.builtin.systemd:
name: "{{{{ item.item.name }}}}" name: "{{{{ item.item.name }}}}"
state: "{{{{ item.item.state }}}}" state: "{{{{ item.item.state }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
loop: "{{{{ _enroll_unit_probes.results | default([]) }}}}" loop: "{{{{ _enroll_unit_probes.results | default([]) }}}}"
when: when:
- item.item.manage | default(false) - item.item.manage | default(false)
@ -755,6 +758,50 @@ def _render_grouped_systemd_tasks(var_prefix: str) -> str:
""" """
def _render_sysctl_tasks(var_prefix: str) -> str:
return f"""- name: Ensure sysctl.d exists
ansible.builtin.file:
path: /etc/sysctl.d
state: directory
owner: root
group: root
mode: "0755"
- name: Deploy captured sysctl configuration
vars:
_enroll_ff:
files:
- "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ {var_prefix}_conf_src_rel }}}}"
- "{{{{ role_path }}}}/files/{{{{ {var_prefix}_conf_src_rel }}}}"
ansible.builtin.copy:
src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
dest: /etc/sysctl.d/99-enroll.conf
owner: root
group: root
mode: "0644"
when: ({var_prefix}_conf_src_rel | default('') | length) > 0
notify: Apply captured sysctl configuration
"""
def _render_sysctl_handlers(var_prefix: str) -> str:
return f"""---
- name: Apply captured sysctl configuration
ansible.builtin.command:
argv:
- sysctl
- -e
- -p
- /etc/sysctl.d/99-enroll.conf
register: _enroll_sysctl_apply
changed_when: false
failed_when:
- not ({var_prefix}_ignore_apply_errors | default(true) | bool)
- _enroll_sysctl_apply.rc != 0
when: {var_prefix}_apply | default(true) | bool
"""
def _render_firewall_runtime_tasks(var_prefix: str) -> str: def _render_firewall_runtime_tasks(var_prefix: str) -> str:
"""Render tasks for live ipset/iptables snapshots.""" """Render tasks for live ipset/iptables snapshots."""
return f"""- name: Ensure firewall runtime snapshot directory exists return f"""- name: Ensure firewall runtime snapshot directory exists
@ -1017,6 +1064,7 @@ def _manifest_from_bundle_dir(
apt_config_snapshot: Dict[str, Any] = roles.get("apt_config", {}) apt_config_snapshot: Dict[str, Any] = roles.get("apt_config", {})
dnf_config_snapshot: Dict[str, Any] = roles.get("dnf_config", {}) dnf_config_snapshot: Dict[str, Any] = roles.get("dnf_config", {})
firewall_runtime_snapshot: Dict[str, Any] = roles.get("firewall_runtime", {}) firewall_runtime_snapshot: Dict[str, Any] = roles.get("firewall_runtime", {})
sysctl_snapshot: Dict[str, Any] = roles.get("sysctl", {})
etc_custom_snapshot: Dict[str, Any] = roles.get("etc_custom", {}) etc_custom_snapshot: Dict[str, Any] = roles.get("etc_custom", {})
usr_local_custom_snapshot: Dict[str, Any] = roles.get("usr_local_custom", {}) usr_local_custom_snapshot: Dict[str, Any] = roles.get("usr_local_custom", {})
extra_paths_snapshot: Dict[str, Any] = roles.get("extra_paths", {}) extra_paths_snapshot: Dict[str, Any] = roles.get("extra_paths", {})
@ -1058,6 +1106,7 @@ def _manifest_from_bundle_dir(
manifested_apt_config_roles: List[str] = [] manifested_apt_config_roles: List[str] = []
manifested_dnf_config_roles: List[str] = [] manifested_dnf_config_roles: List[str] = []
manifested_firewall_runtime_roles: List[str] = [] manifested_firewall_runtime_roles: List[str] = []
manifested_sysctl_roles: List[str] = []
manifested_etc_custom_roles: List[str] = [] manifested_etc_custom_roles: List[str] = []
manifested_usr_local_custom_roles: List[str] = [] manifested_usr_local_custom_roles: List[str] = []
manifested_extra_paths_roles: List[str] = [] manifested_extra_paths_roles: List[str] = []
@ -2041,6 +2090,104 @@ DNF/YUM configuration harvested from the system (repos, config files, and RPM GP
manifested_dnf_config_roles.append(role) manifested_dnf_config_roles.append(role)
# -------------------------
# sysctl role (live writable sysctl state)
# -------------------------
if sysctl_snapshot and (sysctl_snapshot.get("managed_files") or []):
role = sysctl_snapshot.get("role_name", "sysctl")
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
var_prefix = role
managed_files = sysctl_snapshot.get("managed_files", []) or []
conf_src_rel = ""
for mf in managed_files:
if mf.get("path") == "/etc/sysctl.d/99-enroll.conf":
conf_src_rel = mf.get("src_rel") or ""
break
if not conf_src_rel and managed_files:
conf_src_rel = managed_files[0].get("src_rel") or ""
parameters = sysctl_snapshot.get("parameters", {}) or {}
notes = sysctl_snapshot.get("notes", []) or []
# Generated sysctl snapshots are host-specific in site mode.
if site_mode:
_copy_artifacts(
bundle_dir,
role,
_host_role_files_dir(out_dir, fqdn or "", role),
)
else:
_copy_artifacts(bundle_dir, role, os.path.join(role_dir, "files"))
vars_map: Dict[str, Any] = {
f"{var_prefix}_conf_src_rel": conf_src_rel,
f"{var_prefix}_apply": True,
f"{var_prefix}_ignore_apply_errors": True,
}
if site_mode:
_write_role_defaults(
role_dir,
{
f"{var_prefix}_conf_src_rel": "",
f"{var_prefix}_apply": True,
f"{var_prefix}_ignore_apply_errors": True,
},
)
_write_hostvars(out_dir, fqdn or "", role, vars_map)
else:
_write_role_defaults(role_dir, vars_map)
tasks = "---\n" + _render_sysctl_tasks(var_prefix)
with open(
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(tasks.rstrip() + "\n")
handlers_dir = os.path.join(role_dir, "handlers")
os.makedirs(handlers_dir, exist_ok=True)
with open(os.path.join(handlers_dir, "main.yml"), "w", encoding="utf-8") as f:
f.write(_render_sysctl_handlers(var_prefix))
with open(
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
) as f:
f.write("---\ndependencies: []\n")
param_count = len(parameters) if isinstance(parameters, dict) else 0
sample_params = []
if isinstance(parameters, dict):
sample_params = sorted(parameters.keys())[:25]
readme = f"""# {role}
Generated from live writable sysctl state captured during harvest.
This role deploys the captured values to `/etc/sysctl.d/99-enroll.conf`. The generated file intentionally contains writable, single-line sysctl values only; read-only, multiline, action-like, and host-identity keys are skipped to avoid creating a noisy or brittle boot-time configuration.
## Captured parameters
Captured parameter count: {param_count}
{os.linesep.join("- " + x for x in sample_params) or "- (none)"}
{"- ..." if param_count > len(sample_params) else ""}
## Notes
{os.linesep.join("- " + n for n in notes) or "- (none)"}
## Safety notes
- `sysctl_apply` defaults to `true` and applies `/etc/sysctl.d/99-enroll.conf` when the file changes.
- `sysctl_ignore_apply_errors` defaults to `true`, because sysctl availability and writability can vary across kernels, containers, and hardware.
- Review this role before applying it broadly across unlike hosts.
"""
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifested_sysctl_roles.append(role)
# ------------------------- # -------------------------
# firewall_runtime role (live ipset/iptables kernel state) # firewall_runtime role (live ipset/iptables kernel state)
# ------------------------- # -------------------------
@ -2217,6 +2364,7 @@ This role restores live ipset and iptables state only for firewall families wher
- name: Run systemd daemon-reload - name: Run systemd daemon-reload
ansible.builtin.systemd: ansible.builtin.systemd:
daemon_reload: true daemon_reload: true
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
""" """
with open( with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8" os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
@ -2586,6 +2734,7 @@ User-requested extra file harvesting.
- name: Run systemd daemon-reload - name: Run systemd daemon-reload
ansible.builtin.systemd: ansible.builtin.systemd:
daemon_reload: true daemon_reload: true
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
- name: Restart service - name: Restart service
ansible.builtin.service: ansible.builtin.service:
@ -2611,6 +2760,7 @@ User-requested extra file harvesting.
f"""- name: Probe whether systemd unit exists and is manageable f"""- name: Probe whether systemd unit exists and is manageable
ansible.builtin.systemd: ansible.builtin.systemd:
name: "{{{{ {var_prefix}_unit_name }}}}" name: "{{{{ {var_prefix}_unit_name }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
check_mode: true check_mode: true
register: _unit_probe register: _unit_probe
failed_when: false failed_when: false
@ -2621,6 +2771,7 @@ User-requested extra file harvesting.
ansible.builtin.systemd: ansible.builtin.systemd:
name: "{{{{ {var_prefix}_unit_name }}}}" name: "{{{{ {var_prefix}_unit_name }}}}"
enabled: "{{{{ {var_prefix}_systemd_enabled | bool }}}}" enabled: "{{{{ {var_prefix}_systemd_enabled | bool }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
when: when:
- {var_prefix}_manage_unit | default(false) - {var_prefix}_manage_unit | default(false)
- _unit_probe is succeeded - _unit_probe is succeeded
@ -2629,6 +2780,7 @@ User-requested extra file harvesting.
ansible.builtin.systemd: ansible.builtin.systemd:
name: "{{{{ {var_prefix}_unit_name }}}}" name: "{{{{ {var_prefix}_unit_name }}}}"
state: "{{{{ {var_prefix}_systemd_state }}}}" state: "{{{{ {var_prefix}_systemd_state }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
when: when:
- {var_prefix}_manage_unit | default(false) - {var_prefix}_manage_unit | default(false)
- _unit_probe is succeeded - _unit_probe is succeeded
@ -2700,6 +2852,7 @@ Generated from `{unit}`.
+ manifested_snap_roles + manifested_snap_roles
+ manifested_service_roles + manifested_service_roles
+ manifested_firewall_runtime_roles + manifested_firewall_runtime_roles
+ manifested_sysctl_roles
+ manifested_etc_custom_roles + manifested_etc_custom_roles
+ manifested_usr_local_custom_roles + manifested_usr_local_custom_roles
+ manifested_extra_paths_roles + manifested_extra_paths_roles
@ -2854,6 +3007,7 @@ Generated from `{unit}`.
- name: Run systemd daemon-reload - name: Run systemd daemon-reload
ansible.builtin.systemd: ansible.builtin.systemd:
daemon_reload: true daemon_reload: true
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
- name: Restart managed services - name: Restart managed services
ansible.builtin.service: ansible.builtin.service:
@ -3000,6 +3154,7 @@ Common role for package section/group `{section_label}`.
- name: Run systemd daemon-reload - name: Run systemd daemon-reload
ansible.builtin.systemd: ansible.builtin.systemd:
daemon_reload: true daemon_reload: true
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
""" """
with open( with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8" os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
@ -3072,6 +3227,7 @@ Generated for package `{pkg}`.
+ manifested_snap_roles + manifested_snap_roles
+ manifested_users_roles + manifested_users_roles
+ tail_roles + tail_roles
+ manifested_sysctl_roles
+ manifested_firewall_runtime_roles + manifested_firewall_runtime_roles
) )

View file

@ -7,6 +7,7 @@ RESERVED_SINGLETON_ROLE_NAMES = {
"apt_config", "apt_config",
"dnf_config", "dnf_config",
"firewall_runtime", "firewall_runtime",
"sysctl",
"etc_custom", "etc_custom",
"usr_local_custom", "usr_local_custom",
"extra_paths", "extra_paths",

View file

@ -692,6 +692,39 @@
], ],
"type": "object" "type": "object"
}, },
"SysctlSnapshot": {
"additionalProperties": false,
"properties": {
"role_name": {
"const": "sysctl"
},
"managed_files": {
"items": {
"$ref": "#/$defs/ManagedFile"
},
"type": "array"
},
"parameters": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"notes": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"role_name",
"managed_files",
"parameters",
"notes"
],
"type": "object"
},
"FlatpakInstall": { "FlatpakInstall": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@ -884,7 +917,6 @@
"role_name" "role_name"
], ],
"type": "object" "type": "object"
}, },
"SnapSnapshot": { "SnapSnapshot": {
"additionalProperties": false, "additionalProperties": false,
@ -1020,6 +1052,9 @@
"firewall_runtime": { "firewall_runtime": {
"$ref": "#/$defs/FirewallRuntimeSnapshot" "$ref": "#/$defs/FirewallRuntimeSnapshot"
}, },
"sysctl": {
"$ref": "#/$defs/SysctlSnapshot"
},
"flatpak": { "flatpak": {
"$ref": "#/$defs/FlatpakSnapshot" "$ref": "#/$defs/FlatpakSnapshot"
}, },

View file

@ -96,6 +96,7 @@ def _iter_managed_files(state: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]
"users", "users",
"apt_config", "apt_config",
"dnf_config", "dnf_config",
"sysctl",
"etc_custom", "etc_custom",
"usr_local_custom", "usr_local_custom",
"extra_paths", "extra_paths",

View file

@ -303,3 +303,48 @@ def test_service_role_names_do_not_collide_with_singleton_roles():
assert _role_name_from_unit("flatpak.service") == "service_flatpak" assert _role_name_from_unit("flatpak.service") == "service_flatpak"
assert _role_name_from_unit("users.service") == "service_users" assert _role_name_from_unit("users.service") == "service_users"
assert _role_name_from_unit("nginx.service") == "nginx" assert _role_name_from_unit("nginx.service") == "nginx"
def test_parse_sysctl_a_output_keeps_persistable_values(monkeypatch):
monkeypatch.setattr(
h,
"_sysctl_key_is_persistable",
lambda key: (key != "kernel.hostname", "test"),
)
params, skipped = h._parse_sysctl_a_output(
"net.ipv4.ip_forward = 1\n"
"kernel.hostname = example\n"
"malformed line\n"
"dev.cdrom.info = \n"
"net.ipv4.ip_forward = 0\n"
)
assert params == {"net.ipv4.ip_forward": "1"}
assert skipped["non_persistable"] == 1
assert skipped["malformed"] == 1
assert skipped["empty_value"] == 1
assert skipped["duplicate"] == 1
def test_collect_sysctl_snapshot_writes_generated_artifact(monkeypatch, tmp_path: Path):
monkeypatch.setattr(
h,
"_run_capture_command",
lambda command_key, *, timeout=10: (
"net.ipv4.ip_forward = 1\nvm.swappiness = 10\n",
None,
),
)
monkeypatch.setattr(h, "_sysctl_key_is_persistable", lambda key: (True, ""))
snap = h._collect_sysctl_snapshot(str(tmp_path))
assert snap.role_name == "sysctl"
assert snap.parameters == {"net.ipv4.ip_forward": "1", "vm.swappiness": "10"}
assert len(snap.managed_files) == 1
assert snap.managed_files[0].path == "/etc/sysctl.d/99-enroll.conf"
conf = tmp_path / "artifacts" / "sysctl" / "sysctl" / "99-enroll.conf"
text = conf.read_text(encoding="utf-8")
assert "net.ipv4.ip_forward = 1" in text
assert "vm.swappiness = 10" in text

View file

@ -260,6 +260,7 @@ def test_manifest_writes_roles_and_playbook_with_clean_when(tmp_path: Path):
# Service role: systemd management should be gated on foo_manage_unit and a probe. # Service role: systemd management should be gated on foo_manage_unit and a probe.
tasks = (out / "roles" / "foo" / "tasks" / "main.yml").read_text(encoding="utf-8") tasks = (out / "roles" / "foo" / "tasks" / "main.yml").read_text(encoding="utf-8")
assert "- name: Probe whether systemd unit exists and is manageable" in tasks assert "- name: Probe whether systemd unit exists and is manageable" in tasks
assert 'no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"' in tasks
assert "when: foo_manage_unit | default(false)" in tasks assert "when: foo_manage_unit | default(false)" in tasks
assert ( assert (
"when:\n - foo_manage_unit | default(false)\n - _unit_probe is succeeded\n" "when:\n - foo_manage_unit | default(false)\n - _unit_probe is succeeded\n"
@ -618,6 +619,7 @@ def test_manifest_groups_systemd_units_into_common_role(tmp_path: Path):
assert "dest: /etc/NetworkManager/NetworkManager.conf" in defaults assert "dest: /etc/NetworkManager/NetworkManager.conf" in defaults
tasks = (out / "roles" / "net" / "tasks" / "main.yml").read_text(encoding="utf-8") tasks = (out / "roles" / "net" / "tasks" / "main.yml").read_text(encoding="utf-8")
assert "Ensure grouped unit enablement matches harvest" in tasks assert "Ensure grouped unit enablement matches harvest" in tasks
assert 'no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"' in tasks
def test_manifest_fqdn_implies_no_common_roles(tmp_path: Path): def test_manifest_fqdn_implies_no_common_roles(tmp_path: Path):
@ -1811,3 +1813,111 @@ def test_manifest_avoids_package_role_collision_with_flatpak_singleton(tmp_path)
assert (out / "roles" / "package_flatpak" / "tasks" / "main.yml").exists() assert (out / "roles" / "package_flatpak" / "tasks" / "main.yml").exists()
assert "role: flatpak" in playbook assert "role: flatpak" in playbook
assert "role: package_flatpak" in playbook assert "role: package_flatpak" in playbook
def test_manifest_writes_sysctl_role(tmp_path: Path):
bundle = tmp_path / "bundle"
out = tmp_path / "ansible"
(bundle / "artifacts" / "sysctl" / "sysctl").mkdir(parents=True, exist_ok=True)
(bundle / "artifacts" / "sysctl" / "sysctl" / "99-enroll.conf").write_text(
"net.ipv4.ip_forward = 1\n",
encoding="utf-8",
)
state = {
"schema_version": 3,
"host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
"inventory": {"packages": {}},
"roles": {
"users": {
"role_name": "users",
"users": [],
"managed_files": [],
"excluded": [],
"notes": [],
},
"services": [],
"packages": [],
"apt_config": {
"role_name": "apt_config",
"managed_files": [],
"excluded": [],
"notes": [],
},
"dnf_config": {
"role_name": "dnf_config",
"managed_files": [],
"excluded": [],
"notes": [],
},
"sysctl": {
"role_name": "sysctl",
"managed_files": [
{
"path": "/etc/sysctl.d/99-enroll.conf",
"src_rel": "sysctl/99-enroll.conf",
"owner": "root",
"group": "root",
"mode": "0644",
"reason": "system_sysctl",
}
],
"parameters": {"net.ipv4.ip_forward": "1"},
"notes": ["Captured 1 live writable sysctl parameter(s)."],
},
"firewall_runtime": {
"role_name": "firewall_runtime",
"packages": [],
"ipset_save": None,
"ipset_sets": [],
"iptables_v4_save": None,
"iptables_v6_save": None,
"notes": [],
},
"etc_custom": {
"role_name": "etc_custom",
"managed_files": [],
"excluded": [],
"notes": [],
},
"usr_local_custom": {
"role_name": "usr_local_custom",
"managed_files": [],
"excluded": [],
"notes": [],
},
"extra_paths": {
"role_name": "extra_paths",
"include_patterns": [],
"exclude_patterns": [],
"managed_files": [],
"excluded": [],
"notes": [],
},
},
}
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
manifest.manifest(str(bundle), str(out))
tasks = (out / "roles" / "sysctl" / "tasks" / "main.yml").read_text(
encoding="utf-8"
)
assert "dest: /etc/sysctl.d/99-enroll.conf" in tasks
assert "notify: Apply captured sysctl configuration" in tasks
handlers = (out / "roles" / "sysctl" / "handlers" / "main.yml").read_text(
encoding="utf-8"
)
assert "- -p" in handlers
assert "- /etc/sysctl.d/99-enroll.conf" in handlers
defaults = (out / "roles" / "sysctl" / "defaults" / "main.yml").read_text(
encoding="utf-8"
)
assert "sysctl_conf_src_rel: sysctl/99-enroll.conf" in defaults
assert "sysctl_ignore_apply_errors: true" in defaults
pb = (out / "playbook.yml").read_text(encoding="utf-8")
assert "role: sysctl" in pb
assert (out / "roles" / "sysctl" / "files" / "sysctl" / "99-enroll.conf").exists()