Add ability to enroll RH-style systems (DNF5/DNF/RPM)
All checks were successful
CI / test (push) Successful in 5m9s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 17s

This commit is contained in:
Miguel Jacq 2025-12-29 14:59:34 +11:00
parent ad2abed612
commit 984b0fa81b
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
15 changed files with 1400 additions and 254 deletions

View file

@ -1,6 +1,5 @@
from __future__ import annotations
import hashlib
from pathlib import Path
@ -97,58 +96,3 @@ def test_parse_status_conffiles_handles_continuations(tmp_path: Path):
assert m["nginx"]["/etc/nginx/nginx.conf"] == "abcdef"
assert m["nginx"]["/etc/nginx/mime.types"] == "123456"
assert "other" not in m
def test_read_pkg_md5sums_and_file_md5(tmp_path: Path, monkeypatch):
import enroll.debian as d
# Patch /var/lib/dpkg/info/<pkg>.md5sums lookup to a tmp file.
md5_file = tmp_path / "pkg.md5sums"
md5_file.write_text("0123456789abcdef etc/foo.conf\n", encoding="utf-8")
def fake_exists(path: str) -> bool:
return path.endswith("/var/lib/dpkg/info/p1.md5sums")
real_open = open
def fake_open(path: str, *args, **kwargs):
if path.endswith("/var/lib/dpkg/info/p1.md5sums"):
return real_open(md5_file, *args, **kwargs)
return real_open(path, *args, **kwargs)
monkeypatch.setattr(d.os.path, "exists", fake_exists)
monkeypatch.setattr("builtins.open", fake_open)
m = d.read_pkg_md5sums("p1")
assert m == {"etc/foo.conf": "0123456789abcdef"}
content = b"hello world\n"
p = tmp_path / "x"
p.write_bytes(content)
assert d.file_md5(str(p)) == hashlib.md5(content).hexdigest()
def test_stat_triplet_fallbacks(tmp_path: Path, monkeypatch):
import enroll.debian as d
import sys
p = tmp_path / "f"
p.write_text("x", encoding="utf-8")
class FakePwdMod:
@staticmethod
def getpwuid(_): # pragma: no cover
raise KeyError
class FakeGrpMod:
@staticmethod
def getgrgid(_): # pragma: no cover
raise KeyError
# stat_triplet imports pwd/grp inside the function, so patch sys.modules.
monkeypatch.setitem(sys.modules, "pwd", FakePwdMod)
monkeypatch.setitem(sys.modules, "grp", FakeGrpMod)
owner, group, mode = d.stat_triplet(str(p))
assert owner.isdigit()
assert group.isdigit()
assert mode.isdigit() and len(mode) == 4

25
tests/test_fsutil.py Normal file
View file

@ -0,0 +1,25 @@
from __future__ import annotations
import hashlib
import os
from pathlib import Path
from enroll.fsutil import file_md5, stat_triplet
def test_file_md5_matches_hashlib(tmp_path: Path):
p = tmp_path / "x"
p.write_bytes(b"hello world")
expected = hashlib.md5(b"hello world").hexdigest() # nosec
assert file_md5(str(p)) == expected
def test_stat_triplet_reports_mode(tmp_path: Path):
p = tmp_path / "x"
p.write_text("x", encoding="utf-8")
os.chmod(p, 0o600)
owner, group, mode = stat_triplet(str(p))
assert mode == "0600"
assert owner # non-empty string
assert group # non-empty string

View file

@ -2,6 +2,7 @@ import json
from pathlib import Path
import enroll.harvest as h
from enroll.platform import PlatformInfo
from enroll.systemd import UnitInfo
@ -10,6 +11,64 @@ class AllowAllPolicy:
return None
class FakeBackend:
"""Minimal backend stub for harvest tests.
The real backends (dpkg/rpm) enumerate the live system (dpkg status, rpm
databases, etc). These tests instead control all backend behaviour.
"""
def __init__(
self,
*,
name: str,
owned_etc: set[str],
etc_owner_map: dict[str, str],
topdir_to_pkgs: dict[str, set[str]],
pkg_to_etc_paths: dict[str, list[str]],
manual_pkgs: list[str],
owner_fn,
modified_by_pkg: dict[str, dict[str, str]] | None = None,
pkg_config_prefixes: tuple[str, ...] = ("/etc/apt/",),
):
self.name = name
self.pkg_config_prefixes = pkg_config_prefixes
self._owned_etc = owned_etc
self._etc_owner_map = etc_owner_map
self._topdir_to_pkgs = topdir_to_pkgs
self._pkg_to_etc_paths = pkg_to_etc_paths
self._manual = manual_pkgs
self._owner_fn = owner_fn
self._modified_by_pkg = modified_by_pkg or {}
def build_etc_index(self):
return (
self._owned_etc,
self._etc_owner_map,
self._topdir_to_pkgs,
self._pkg_to_etc_paths,
)
def owner_of_path(self, path: str):
return self._owner_fn(path)
def list_manual_packages(self):
return list(self._manual)
def specific_paths_for_hints(self, hints: set[str]):
return []
def is_pkg_config_path(self, path: str) -> bool:
for pfx in self.pkg_config_prefixes:
if path == pfx or path.startswith(pfx):
return True
return False
def modified_paths(self, pkg: str, etc_paths: list[str]):
# Test-controlled; ignore etc_paths.
return dict(self._modified_by_pkg.get(pkg, {}))
def test_harvest_dedup_manual_packages_and_builds_etc_custom(
monkeypatch, tmp_path: Path
):
@ -22,7 +81,7 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
real_exists = os.path.exists
real_islink = os.path.islink
# Fake filesystem: two /etc files exist, only one is dpkg-owned.
# Fake filesystem: two /etc files exist, only one is package-owned.
# Also include some /usr/local files to populate usr_local_custom.
files = {
"/etc/openvpn/server.conf": b"server",
@ -93,6 +152,7 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
# Avoid real system access
monkeypatch.setattr(h, "list_enabled_services", lambda: ["openvpn.service"])
monkeypatch.setattr(h, "list_enabled_timers", lambda: [])
monkeypatch.setattr(
h,
"get_unit_info",
@ -109,29 +169,30 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
),
)
# Debian package index: openvpn owns /etc/openvpn/server.conf; keyboard is unowned.
def fake_build_index():
owned_etc = {"/etc/openvpn/server.conf"}
etc_owner_map = {"/etc/openvpn/server.conf": "openvpn"}
topdir_to_pkgs = {"openvpn": {"openvpn"}}
pkg_to_etc_paths = {"openvpn": ["/etc/openvpn/server.conf"], "curl": []}
return owned_etc, etc_owner_map, topdir_to_pkgs, pkg_to_etc_paths
# Package index: openvpn owns /etc/openvpn/server.conf; keyboard is unowned.
owned_etc = {"/etc/openvpn/server.conf"}
etc_owner_map = {"/etc/openvpn/server.conf": "openvpn"}
topdir_to_pkgs = {"openvpn": {"openvpn"}}
pkg_to_etc_paths = {"openvpn": ["/etc/openvpn/server.conf"], "curl": []}
monkeypatch.setattr(h, "build_dpkg_etc_index", fake_build_index)
# openvpn conffile hash mismatch => should be captured under service role
monkeypatch.setattr(
h,
"parse_status_conffiles",
lambda: {"openvpn": {"/etc/openvpn/server.conf": "old"}},
backend = FakeBackend(
name="dpkg",
owned_etc=owned_etc,
etc_owner_map=etc_owner_map,
topdir_to_pkgs=topdir_to_pkgs,
pkg_to_etc_paths=pkg_to_etc_paths,
manual_pkgs=["openvpn", "curl"],
owner_fn=lambda p: "openvpn" if "openvpn" in (p or "") else None,
modified_by_pkg={
"openvpn": {"/etc/openvpn/server.conf": "modified_conffile"},
},
)
monkeypatch.setattr(h, "read_pkg_md5sums", lambda pkg: {})
monkeypatch.setattr(h, "file_md5", lambda path: "new")
monkeypatch.setattr(
h, "dpkg_owner", lambda p: "openvpn" if "openvpn" in p else None
h, "detect_platform", lambda: PlatformInfo("debian", "dpkg", {})
)
monkeypatch.setattr(h, "list_manual_packages", lambda: ["openvpn", "curl"])
monkeypatch.setattr(h, "get_backend", lambda info=None: backend)
monkeypatch.setattr(h, "collect_non_system_users", lambda: [])
def fake_stat_triplet(p: str):
@ -207,6 +268,7 @@ def test_shared_cron_snippet_prefers_matching_role_over_lexicographic(
monkeypatch.setattr(
h, "list_enabled_services", lambda: ["apparmor.service", "ntpsec.service"]
)
monkeypatch.setattr(h, "list_enabled_timers", lambda: [])
def fake_unit_info(unit: str) -> UnitInfo:
if unit == "apparmor.service":
@ -235,31 +297,35 @@ def test_shared_cron_snippet_prefers_matching_role_over_lexicographic(
monkeypatch.setattr(h, "get_unit_info", fake_unit_info)
# Dpkg /etc index: no owned /etc paths needed for this test.
monkeypatch.setattr(
h,
"build_dpkg_etc_index",
lambda: (set(), {}, {}, {}),
)
monkeypatch.setattr(h, "parse_status_conffiles", lambda: {})
monkeypatch.setattr(h, "read_pkg_md5sums", lambda pkg: {})
monkeypatch.setattr(h, "file_md5", lambda path: "x")
monkeypatch.setattr(h, "list_manual_packages", lambda: [])
monkeypatch.setattr(h, "collect_non_system_users", lambda: [])
# Make apparmor *also* claim the ntpsec package (simulates overly-broad
# package inference). The snippet routing should still prefer role 'ntpsec'.
def fake_dpkg_owner(p: str):
def fake_owner(p: str):
if p == "/etc/cron.d/ntpsec":
return "ntpsec"
if "apparmor" in p:
if "apparmor" in (p or ""):
return "ntpsec" # intentionally misleading
if "ntpsec" in p or "ntpd" in p:
if "ntpsec" in (p or "") or "ntpd" in (p or ""):
return "ntpsec"
return None
monkeypatch.setattr(h, "dpkg_owner", fake_dpkg_owner)
backend = FakeBackend(
name="dpkg",
owned_etc=set(),
etc_owner_map={},
topdir_to_pkgs={},
pkg_to_etc_paths={},
manual_pkgs=[],
owner_fn=fake_owner,
modified_by_pkg={},
)
monkeypatch.setattr(
h, "detect_platform", lambda: PlatformInfo("debian", "dpkg", {})
)
monkeypatch.setattr(h, "get_backend", lambda info=None: backend)
monkeypatch.setattr(h, "stat_triplet", lambda p: ("root", "root", "0644"))
monkeypatch.setattr(h, "collect_non_system_users", lambda: [])
def fake_copy(bundle_dir: str, role_name: str, abs_path: str, src_rel: str):
dst = Path(bundle_dir) / "artifacts" / role_name / src_rel
@ -268,11 +334,7 @@ def test_shared_cron_snippet_prefers_matching_role_over_lexicographic(
monkeypatch.setattr(h, "_copy_into_bundle", fake_copy)
class AllowAll:
def deny_reason(self, path: str):
return None
state_path = h.harvest(str(bundle), policy=AllowAll())
state_path = h.harvest(str(bundle), policy=AllowAllPolicy())
st = json.loads(Path(state_path).read_text(encoding="utf-8"))
# Cron snippet should end up attached to the ntpsec role, not apparmor.

View file

@ -322,3 +322,96 @@ def test_copy2_replace_overwrites_readonly_destination(tmp_path: Path):
assert dst.read_text(encoding="utf-8") == "new"
mode = stat.S_IMODE(dst.stat().st_mode)
assert mode & stat.S_IWUSR # destination should remain mergeable
def test_manifest_includes_dnf_config_role_when_present(tmp_path: Path):
bundle = tmp_path / "bundle"
out = tmp_path / "ansible"
# Create a dnf_config artifact.
(bundle / "artifacts" / "dnf_config" / "etc" / "dnf").mkdir(
parents=True, exist_ok=True
)
(bundle / "artifacts" / "dnf_config" / "etc" / "dnf" / "dnf.conf").write_text(
"[main]\n", encoding="utf-8"
)
state = {
"host": {"hostname": "test", "os": "redhat", "pkg_backend": "rpm"},
"users": {
"role_name": "users",
"users": [],
"managed_files": [],
"excluded": [],
"notes": [],
},
"services": [],
"package_roles": [],
"manual_packages": [],
"manual_packages_skipped": [],
"apt_config": {
"role_name": "apt_config",
"managed_files": [],
"excluded": [],
"notes": [],
},
"dnf_config": {
"role_name": "dnf_config",
"managed_files": [
{
"path": "/etc/dnf/dnf.conf",
"src_rel": "etc/dnf/dnf.conf",
"owner": "root",
"group": "root",
"mode": "0644",
"reason": "dnf_config",
}
],
"excluded": [],
"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.mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
manifest(str(bundle), str(out))
pb = (out / "playbook.yml").read_text(encoding="utf-8")
assert "- dnf_config" in pb
tasks = (out / "roles" / "dnf_config" / "tasks" / "main.yml").read_text(
encoding="utf-8"
)
# Ensure the role exists and contains some file deployment logic.
assert "Deploy any other managed files" in tasks
def test_render_install_packages_tasks_contains_dnf_branch():
from enroll.manifest import _render_install_packages_tasks
txt = _render_install_packages_tasks("role", "role")
assert "ansible.builtin.apt" in txt
assert "ansible.builtin.dnf" in txt
assert "ansible.builtin.package" in txt
assert "pkg_mgr" in txt

93
tests/test_platform.py Normal file
View file

@ -0,0 +1,93 @@
from __future__ import annotations
from pathlib import Path
import enroll.platform as platform
def test_read_os_release_parses_kv_and_strips_quotes(tmp_path: Path):
p = tmp_path / "os-release"
p.write_text(
"""
# comment
ID=fedora
ID_LIKE=\"rhel centos\"
NAME=\"Fedora Linux\"
EMPTY=
NOEQUALS
""",
encoding="utf-8",
)
osr = platform._read_os_release(str(p))
assert osr["ID"] == "fedora"
assert osr["ID_LIKE"] == "rhel centos"
assert osr["NAME"] == "Fedora Linux"
assert osr["EMPTY"] == ""
assert "NOEQUALS" not in osr
def test_detect_platform_prefers_os_release(monkeypatch):
monkeypatch.setattr(
platform,
"_read_os_release",
lambda path="/etc/os-release": {"ID": "fedora", "ID_LIKE": "rhel"},
)
# If os-release is decisive we shouldn't need which()
monkeypatch.setattr(platform.shutil, "which", lambda exe: None)
info = platform.detect_platform()
assert info.os_family == "redhat"
assert info.pkg_backend == "rpm"
def test_detect_platform_fallbacks_to_dpkg_when_unknown(monkeypatch):
monkeypatch.setattr(platform, "_read_os_release", lambda path="/etc/os-release": {})
monkeypatch.setattr(
platform.shutil, "which", lambda exe: "/usr/bin/dpkg" if exe == "dpkg" else None
)
info = platform.detect_platform()
assert info.os_family == "debian"
assert info.pkg_backend == "dpkg"
def test_get_backend_unknown_prefers_rpm_if_present(monkeypatch):
monkeypatch.setattr(
platform.shutil, "which", lambda exe: "/usr/bin/rpm" if exe == "rpm" else None
)
b = platform.get_backend(
platform.PlatformInfo(os_family="unknown", pkg_backend="unknown", os_release={})
)
assert isinstance(b, platform.RpmBackend)
def test_rpm_backend_modified_paths_labels_conffiles(monkeypatch):
b = platform.RpmBackend()
# Pretend rpm -V says both files changed, but only one is a config file.
monkeypatch.setattr(b, "_modified_files", lambda pkg: {"/etc/foo.conf", "/etc/bar"})
monkeypatch.setattr(b, "_config_files", lambda pkg: {"/etc/foo.conf"})
out = b.modified_paths("mypkg", ["/etc/foo.conf", "/etc/bar", "/etc/dnf/dnf.conf"])
assert out["/etc/foo.conf"] == "modified_conffile"
assert out["/etc/bar"] == "modified_packaged_file"
# Package-manager config paths are excluded.
assert "/etc/dnf/dnf.conf" not in out
def test_specific_paths_for_hints_differs_between_backends():
# We can exercise this without instantiating DpkgBackend (which reads dpkg status)
class Dummy(platform.PackageBackend):
name = "dummy"
pkg_config_prefixes = ("/etc/apt/",)
d = Dummy()
assert d.is_pkg_config_path("/etc/apt/sources.list")
assert not d.is_pkg_config_path("/etc/ssh/sshd_config")
r = platform.RpmBackend()
paths = set(r.specific_paths_for_hints({"nginx"}))
assert "/etc/sysconfig/nginx" in paths
assert "/etc/sysconfig/nginx.conf" in paths

131
tests/test_rpm.py Normal file
View file

@ -0,0 +1,131 @@
from __future__ import annotations
import enroll.rpm as rpm
def test_rpm_owner_returns_none_when_unowned(monkeypatch):
monkeypatch.setattr(
rpm,
"_run",
lambda cmd, allow_fail=False, merge_err=False: (
1,
"file /etc/x is not owned by any package\n",
),
)
assert rpm.rpm_owner("/etc/x") is None
def test_rpm_owner_parses_name(monkeypatch):
monkeypatch.setattr(
rpm, "_run", lambda cmd, allow_fail=False, merge_err=False: (0, "bash\n")
)
assert rpm.rpm_owner("/bin/bash") == "bash"
def test_strip_arch_strips_known_arches():
assert rpm._strip_arch("vim-enhanced.x86_64") == "vim-enhanced"
assert rpm._strip_arch("foo.noarch") == "foo"
assert rpm._strip_arch("weird.token") == "weird.token"
def test_list_manual_packages_prefers_dnf_repoquery(monkeypatch):
monkeypatch.setattr(
rpm.shutil, "which", lambda exe: "/usr/bin/dnf" if exe == "dnf" else None
)
def fake_run(cmd, allow_fail=False, merge_err=False):
# First repoquery form returns usable output.
if cmd[:3] == ["dnf", "-q", "repoquery"]:
return 0, "vim-enhanced.x86_64\nhtop\nvim-enhanced.x86_64\n"
raise AssertionError(f"unexpected cmd: {cmd}")
monkeypatch.setattr(rpm, "_run", fake_run)
pkgs = rpm.list_manual_packages()
assert pkgs == ["htop", "vim-enhanced"]
def test_list_manual_packages_falls_back_to_history(monkeypatch):
monkeypatch.setattr(
rpm.shutil, "which", lambda exe: "/usr/bin/dnf" if exe == "dnf" else None
)
def fake_run(cmd, allow_fail=False, merge_err=False):
# repoquery fails
if cmd[:3] == ["dnf", "-q", "repoquery"]:
return 1, ""
if cmd[:3] == ["dnf", "-q", "history"]:
return (
0,
"Installed Packages\nvim-enhanced.x86_64\nLast metadata expiration check: 0:01:00 ago\n",
)
raise AssertionError(f"unexpected cmd: {cmd}")
monkeypatch.setattr(rpm, "_run", fake_run)
pkgs = rpm.list_manual_packages()
assert pkgs == ["vim-enhanced"]
def test_build_rpm_etc_index_uses_fallback_when_rpm_output_mismatches(monkeypatch):
# Two files in /etc, one owned, one unowned.
monkeypatch.setattr(
rpm, "_walk_etc_files", lambda: ["/etc/owned.conf", "/etc/unowned.conf"]
)
# Simulate chunk query producing unexpected extra line (mismatch) -> triggers per-file fallback.
monkeypatch.setattr(
rpm,
"_run",
lambda cmd, allow_fail=False, merge_err=False: (0, "ownedpkg\nEXTRA\nTHIRD\n"),
)
monkeypatch.setattr(
rpm, "rpm_owner", lambda p: "ownedpkg" if p == "/etc/owned.conf" else None
)
owned, owner_map, topdir_to_pkgs, pkg_to_etc = rpm.build_rpm_etc_index()
assert owned == {"/etc/owned.conf"}
assert owner_map["/etc/owned.conf"] == "ownedpkg"
assert "owned.conf" in topdir_to_pkgs
assert pkg_to_etc["ownedpkg"] == ["/etc/owned.conf"]
def test_build_rpm_etc_index_parses_chunk_output(monkeypatch):
monkeypatch.setattr(
rpm, "_walk_etc_files", lambda: ["/etc/ssh/sshd_config", "/etc/notowned"]
)
def fake_run(cmd, allow_fail=False, merge_err=False):
# One output line per input path.
return 0, "openssh-server\nfile /etc/notowned is not owned by any package\n"
monkeypatch.setattr(rpm, "_run", fake_run)
owned, owner_map, topdir_to_pkgs, pkg_to_etc = rpm.build_rpm_etc_index()
assert "/etc/ssh/sshd_config" in owned
assert "/etc/notowned" not in owned
assert owner_map["/etc/ssh/sshd_config"] == "openssh-server"
assert "ssh" in topdir_to_pkgs
assert "openssh-server" in topdir_to_pkgs["ssh"]
assert pkg_to_etc["openssh-server"] == ["/etc/ssh/sshd_config"]
def test_rpm_config_files_and_modified_files_parsing(monkeypatch):
monkeypatch.setattr(
rpm,
"_run",
lambda cmd, allow_fail=False, merge_err=False: (
0,
"/etc/foo.conf\n/usr/bin/tool\n",
),
)
assert rpm.rpm_config_files("mypkg") == {"/etc/foo.conf", "/usr/bin/tool"}
# rpm -V returns only changed/missing files
out = "S.5....T. c /etc/foo.conf\nmissing /etc/bar\n"
monkeypatch.setattr(
rpm, "_run", lambda cmd, allow_fail=False, merge_err=False: (1, out)
)
assert rpm.rpm_modified_files("mypkg") == {"/etc/foo.conf", "/etc/bar"}