From 9546e1b8ed6170c1a41df47667232032d47841eb Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 16 Jun 2026 14:23:44 +1000 Subject: [PATCH] Add sysctl detection --- CHANGELOG.md | 2 + README.md | 2 + enroll/diff.py | 6 ++ enroll/explain.py | 2 + enroll/harvest.py | 184 +++++++++++++++++++++++++++++++- enroll/manifest.py | 156 +++++++++++++++++++++++++++ enroll/role_names.py | 1 + enroll/schema/state.schema.json | 37 ++++++- enroll/validate.py | 1 + tests/test_harvest_helpers.py | 45 ++++++++ tests/test_manifest.py | 110 +++++++++++++++++++ 11 files changed, 544 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c24d38c..4fab2ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ * 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: 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 diff --git a/README.md b/README.md index b6d6e39..d508b6b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - 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 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 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 @@ -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 - 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*` +- 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) - Optional user-specified extra files/dirs via `--include-path` (emitted as an `extra_paths` role at manifest time) diff --git a/enroll/diff.py b/enroll/diff.py index 8d54bb1..eb496a5 100644 --- a/enroll/diff.py +++ b/enroll/diff.py @@ -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 []: 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 ec = _roles(state).get("etc_custom") or {} ec_role = ec.get("role_name") or "etc_custom" diff --git a/enroll/explain.py b/enroll/explain.py index 131f2df..b4f4de8 100644 --- a/enroll/explain.py +++ b/enroll/explain.py @@ -383,6 +383,7 @@ def explain_state( for rname in [ "apt_config", "dnf_config", + "sysctl", "etc_custom", "usr_local_custom", "extra_paths", @@ -435,6 +436,7 @@ def explain_state( for rname in [ "apt_config", "dnf_config", + "sysctl", "etc_custom", "usr_local_custom", "extra_paths", diff --git a/enroll/harvest.py b/enroll/harvest.py index 0f9aa1e..3e915cb 100644 --- a/enroll/harvest.py +++ b/enroll/harvest.py @@ -181,6 +181,14 @@ class FirewallRuntimeSnapshot: 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 = { ".cfg", ".cnf", @@ -1049,6 +1057,7 @@ _FIREWALL_CAPTURE_COMMANDS: Dict[str, Tuple[str, ...]] = { "ipset_save": ("ipset", "save"), "iptables_v4_save": ("iptables-save",), "iptables_v6_save": ("ip6tables-save",), + "sysctl_all": ("sysctl", "-a"), } @@ -1104,6 +1113,165 @@ def _write_generated_artifact( 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: for raw in (text or "").splitlines(): line = raw.strip() @@ -1300,13 +1468,20 @@ def harvest( _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( role_name="firewall_runtime", notes=[ "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: firewall_runtime_snapshot = _collect_firewall_runtime_snapshot( bundle_dir, @@ -1314,6 +1489,12 @@ def harvest( persistent_iptables_v4_files=persistent_iptables_v4_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]: for c in cands: @@ -2682,6 +2863,7 @@ def harvest( "apt_config": asdict(apt_config_snapshot), "dnf_config": asdict(dnf_config_snapshot), "firewall_runtime": asdict(firewall_runtime_snapshot), + "sysctl": asdict(sysctl_snapshot), "etc_custom": asdict(etc_custom_snapshot), "usr_local_custom": asdict(usr_local_custom_snapshot), "extra_paths": asdict(extra_paths_snapshot), diff --git a/enroll/manifest.py b/enroll/manifest.py index dc78bf5..a643f48 100644 --- a/enroll/manifest.py +++ b/enroll/manifest.py @@ -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 ansible.builtin.systemd: name: "{{{{ item.name }}}}" + no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}" check_mode: true loop: "{{{{ {var_prefix}_systemd_units | default([]) }}}}" register: _enroll_unit_probes @@ -739,6 +740,7 @@ def _render_grouped_systemd_tasks(var_prefix: str) -> str: ansible.builtin.systemd: name: "{{{{ item.item.name }}}}" enabled: "{{{{ item.item.enabled | bool }}}}" + no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}" loop: "{{{{ _enroll_unit_probes.results | default([]) }}}}" when: - item.item.manage | default(false) @@ -748,6 +750,7 @@ def _render_grouped_systemd_tasks(var_prefix: str) -> str: ansible.builtin.systemd: name: "{{{{ item.item.name }}}}" state: "{{{{ item.item.state }}}}" + no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}" loop: "{{{{ _enroll_unit_probes.results | default([]) }}}}" when: - 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: """Render tasks for live ipset/iptables snapshots.""" 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", {}) dnf_config_snapshot: Dict[str, Any] = roles.get("dnf_config", {}) 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", {}) usr_local_custom_snapshot: Dict[str, Any] = roles.get("usr_local_custom", {}) 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_dnf_config_roles: List[str] = [] manifested_firewall_runtime_roles: List[str] = [] + manifested_sysctl_roles: List[str] = [] manifested_etc_custom_roles: List[str] = [] manifested_usr_local_custom_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) + # ------------------------- + # 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) # ------------------------- @@ -2217,6 +2364,7 @@ This role restores live ipset and iptables state only for firewall families wher - name: Run systemd daemon-reload ansible.builtin.systemd: daemon_reload: true + no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}" """ with open( 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 ansible.builtin.systemd: daemon_reload: true + no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}" - name: Restart service ansible.builtin.service: @@ -2611,6 +2760,7 @@ User-requested extra file harvesting. f"""- name: Probe whether systemd unit exists and is manageable ansible.builtin.systemd: name: "{{{{ {var_prefix}_unit_name }}}}" + no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}" check_mode: true register: _unit_probe failed_when: false @@ -2621,6 +2771,7 @@ User-requested extra file harvesting. ansible.builtin.systemd: name: "{{{{ {var_prefix}_unit_name }}}}" enabled: "{{{{ {var_prefix}_systemd_enabled | bool }}}}" + no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}" when: - {var_prefix}_manage_unit | default(false) - _unit_probe is succeeded @@ -2629,6 +2780,7 @@ User-requested extra file harvesting. ansible.builtin.systemd: name: "{{{{ {var_prefix}_unit_name }}}}" state: "{{{{ {var_prefix}_systemd_state }}}}" + no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}" when: - {var_prefix}_manage_unit | default(false) - _unit_probe is succeeded @@ -2700,6 +2852,7 @@ Generated from `{unit}`. + manifested_snap_roles + manifested_service_roles + manifested_firewall_runtime_roles + + manifested_sysctl_roles + manifested_etc_custom_roles + manifested_usr_local_custom_roles + manifested_extra_paths_roles @@ -2854,6 +3007,7 @@ Generated from `{unit}`. - name: Run systemd daemon-reload ansible.builtin.systemd: daemon_reload: true + no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}" - name: Restart managed services ansible.builtin.service: @@ -3000,6 +3154,7 @@ Common role for package section/group `{section_label}`. - name: Run systemd daemon-reload ansible.builtin.systemd: daemon_reload: true + no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}" """ with open( os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8" @@ -3072,6 +3227,7 @@ Generated for package `{pkg}`. + manifested_snap_roles + manifested_users_roles + tail_roles + + manifested_sysctl_roles + manifested_firewall_runtime_roles ) diff --git a/enroll/role_names.py b/enroll/role_names.py index b3fa584..b3ab3d3 100644 --- a/enroll/role_names.py +++ b/enroll/role_names.py @@ -7,6 +7,7 @@ RESERVED_SINGLETON_ROLE_NAMES = { "apt_config", "dnf_config", "firewall_runtime", + "sysctl", "etc_custom", "usr_local_custom", "extra_paths", diff --git a/enroll/schema/state.schema.json b/enroll/schema/state.schema.json index 11f8672..a67cf64 100644 --- a/enroll/schema/state.schema.json +++ b/enroll/schema/state.schema.json @@ -692,6 +692,39 @@ ], "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": { "additionalProperties": false, "properties": { @@ -884,7 +917,6 @@ "role_name" ], "type": "object" - }, "SnapSnapshot": { "additionalProperties": false, @@ -1020,6 +1052,9 @@ "firewall_runtime": { "$ref": "#/$defs/FirewallRuntimeSnapshot" }, + "sysctl": { + "$ref": "#/$defs/SysctlSnapshot" + }, "flatpak": { "$ref": "#/$defs/FlatpakSnapshot" }, diff --git a/enroll/validate.py b/enroll/validate.py index f3291e9..48d7250 100644 --- a/enroll/validate.py +++ b/enroll/validate.py @@ -96,6 +96,7 @@ def _iter_managed_files(state: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any] "users", "apt_config", "dnf_config", + "sysctl", "etc_custom", "usr_local_custom", "extra_paths", diff --git a/tests/test_harvest_helpers.py b/tests/test_harvest_helpers.py index 53e7d58..03ec9ed 100644 --- a/tests/test_harvest_helpers.py +++ b/tests/test_harvest_helpers.py @@ -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("users.service") == "service_users" 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 diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 3accec9..8c74064 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -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. 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 'no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"' in tasks assert "when: foo_manage_unit | default(false)" in tasks assert ( "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 tasks = (out / "roles" / "net" / "tasks" / "main.yml").read_text(encoding="utf-8") 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): @@ -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 "role: 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()