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
|
* 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue