Add sysctl detection
This commit is contained in:
parent
3c19ae54b2
commit
9546e1b8ed
11 changed files with 544 additions and 2 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ RESERVED_SINGLETON_ROLE_NAMES = {
|
|||
"apt_config",
|
||||
"dnf_config",
|
||||
"firewall_runtime",
|
||||
"sysctl",
|
||||
"etc_custom",
|
||||
"usr_local_custom",
|
||||
"extra_paths",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue