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,7 +1,6 @@
from __future__ import annotations
import glob
import hashlib
import os
import subprocess # nosec
from typing import Dict, List, Optional, Set, Tuple
@ -180,28 +179,3 @@ def read_pkg_md5sums(pkg: str) -> Dict[str, str]:
md5, rel = line.split(None, 1)
m[rel.strip()] = md5.strip()
return m
def file_md5(path: str) -> str:
h = hashlib.md5() # nosec
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def stat_triplet(path: str) -> Tuple[str, str, str]:
st = os.stat(path, follow_symlinks=True)
mode = oct(st.st_mode & 0o777)[2:].zfill(4)
import pwd, grp
try:
owner = pwd.getpwuid(st.st_uid).pw_name
except KeyError:
owner = str(st.st_uid)
try:
group = grp.getgrgid(st.st_gid).gr_name
except KeyError:
group = str(st.st_gid)
return owner, group, mode

40
enroll/fsutil.py Normal file
View file

@ -0,0 +1,40 @@
from __future__ import annotations
import hashlib
import os
from typing import Tuple
def file_md5(path: str) -> str:
"""Return hex MD5 of a file.
Used for Debian dpkg baseline comparisons.
"""
h = hashlib.md5() # nosec
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def stat_triplet(path: str) -> Tuple[str, str, str]:
"""Return (owner, group, mode) for a path.
owner/group are usernames/group names when resolvable, otherwise numeric ids.
mode is a zero-padded octal string (e.g. "0644").
"""
st = os.stat(path, follow_symlinks=True)
mode = oct(st.st_mode & 0o777)[2:].zfill(4)
import grp
import pwd
try:
owner = pwd.getpwuid(st.st_uid).pw_name
except KeyError:
owner = str(st.st_uid)
try:
group = grp.getgrgid(st.st_gid).gr_name
except KeyError:
group = str(st.st_gid)
return owner, group, mode

View file

@ -15,18 +15,12 @@ from .systemd import (
get_timer_info,
UnitQueryError,
)
from .debian import (
build_dpkg_etc_index,
dpkg_owner,
file_md5,
list_manual_packages,
parse_status_conffiles,
read_pkg_md5sums,
stat_triplet,
)
from .fsutil import stat_triplet
from .platform import detect_platform, get_backend
from .ignore import IgnorePolicy
from .pathfilter import PathFilter, expand_includes
from .accounts import collect_non_system_users
from .version import get_enroll_version
@dataclass
@ -85,6 +79,14 @@ class AptConfigSnapshot:
notes: List[str]
@dataclass
class DnfConfigSnapshot:
role_name: str
managed_files: List[ManagedFile]
excluded: List[ExcludedFile]
notes: List[str]
@dataclass
class EtcCustomSnapshot:
role_name: str
@ -158,6 +160,13 @@ SHARED_ETC_TOPDIRS = {
"sudoers.d",
"sysctl.d",
"systemd",
# RPM-family shared trees
"dnf",
"yum",
"yum.repos.d",
"sysconfig",
"pki",
"firewalld",
}
@ -314,17 +323,23 @@ def _add_pkgs_from_etc_topdirs(
pkgs.add(p)
def _maybe_add_specific_paths(hints: Set[str]) -> List[str]:
paths: List[str] = []
for h in hints:
paths.extend(
[
f"/etc/default/{h}",
f"/etc/init.d/{h}",
f"/etc/sysctl.d/{h}.conf",
]
)
return paths
def _maybe_add_specific_paths(hints: Set[str], backend) -> List[str]:
# Delegate to backend-specific conventions (e.g. /etc/default on Debian,
# /etc/sysconfig on Fedora/RHEL). Always include sysctl.d.
try:
return backend.specific_paths_for_hints(hints)
except Exception:
# Best-effort fallback (Debian-ish).
paths: List[str] = []
for h in hints:
paths.extend(
[
f"/etc/default/{h}",
f"/etc/init.d/{h}",
f"/etc/sysctl.d/{h}.conf",
]
)
return paths
def _scan_unowned_under_roots(
@ -408,6 +423,7 @@ _SYSTEM_CAPTURE_GLOBS: List[tuple[str, str]] = [
("/etc/anacron/*", "system_cron"),
("/var/spool/cron/crontabs/*", "system_cron"),
("/var/spool/crontabs/*", "system_cron"),
("/var/spool/cron/*", "system_cron"),
# network
("/etc/netplan/*", "system_network"),
("/etc/systemd/network/*", "system_network"),
@ -415,6 +431,9 @@ _SYSTEM_CAPTURE_GLOBS: List[tuple[str, str]] = [
("/etc/network/interfaces.d/*", "system_network"),
("/etc/resolvconf.conf", "system_network"),
("/etc/resolvconf/resolv.conf.d/*", "system_network"),
("/etc/NetworkManager/system-connections/*", "system_network"),
("/etc/sysconfig/network*", "system_network"),
("/etc/sysconfig/network-scripts/*", "system_network"),
# firewall
("/etc/nftables.conf", "system_firewall"),
("/etc/nftables.d/*", "system_firewall"),
@ -422,6 +441,10 @@ _SYSTEM_CAPTURE_GLOBS: List[tuple[str, str]] = [
("/etc/iptables/rules.v6", "system_firewall"),
("/etc/ufw/*", "system_firewall"),
("/etc/default/ufw", "system_firewall"),
("/etc/firewalld/*", "system_firewall"),
("/etc/firewalld/zones/*", "system_firewall"),
# SELinux
("/etc/selinux/config", "system_security"),
# other
("/etc/rc.local", "system_rc"),
]
@ -553,6 +576,51 @@ def _iter_apt_capture_paths() -> List[tuple[str, str]]:
return uniq
def _iter_dnf_capture_paths() -> List[tuple[str, str]]:
"""Return (path, reason) pairs for DNF/YUM configuration on RPM systems.
Captures:
- /etc/dnf/* (dnf.conf, vars, plugins, modules, automatic)
- /etc/yum.conf (legacy)
- /etc/yum.repos.d/*.repo
- /etc/pki/rpm-gpg/* (GPG key files)
"""
reasons: Dict[str, str] = {}
for root, tag in (
("/etc/dnf", "dnf_config"),
("/etc/yum", "yum_config"),
):
if os.path.isdir(root):
for dirpath, _, filenames in os.walk(root):
for fn in filenames:
p = os.path.join(dirpath, fn)
if os.path.islink(p) or not os.path.isfile(p):
continue
reasons.setdefault(p, tag)
# Legacy yum.conf.
if os.path.isfile("/etc/yum.conf") and not os.path.islink("/etc/yum.conf"):
reasons.setdefault("/etc/yum.conf", "yum_conf")
# Repositories.
if os.path.isdir("/etc/yum.repos.d"):
for p in _iter_matching_files("/etc/yum.repos.d/*.repo"):
reasons[p] = "yum_repo"
# RPM GPG keys.
if os.path.isdir("/etc/pki/rpm-gpg"):
for dirpath, _, filenames in os.walk("/etc/pki/rpm-gpg"):
for fn in filenames:
p = os.path.join(dirpath, fn)
if os.path.islink(p) or not os.path.isfile(p):
continue
reasons.setdefault(p, "rpm_gpg_key")
# Stable ordering.
return [(p, reasons[p]) for p in sorted(reasons.keys())]
def _iter_system_capture_paths() -> List[tuple[str, str]]:
"""Return (path, reason) pairs for essential system config/state (non-APT)."""
out: List[tuple[str, str]] = []
@ -600,8 +668,12 @@ def harvest(
flush=True,
)
owned_etc, etc_owner_map, topdir_to_pkgs, pkg_to_etc_paths = build_dpkg_etc_index()
conffiles_by_pkg = parse_status_conffiles()
platform = detect_platform()
backend = get_backend(platform)
owned_etc, etc_owner_map, topdir_to_pkgs, pkg_to_etc_paths = (
backend.build_etc_index()
)
# -------------------------
# Service roles
@ -645,12 +717,12 @@ def harvest(
candidates: Dict[str, str] = {}
if ui.fragment_path:
p = dpkg_owner(ui.fragment_path)
p = backend.owner_of_path(ui.fragment_path)
if p:
pkgs.add(p)
for exe in ui.exec_paths:
p = dpkg_owner(exe)
p = backend.owner_of_path(exe)
if p:
pkgs.add(p)
@ -675,7 +747,7 @@ def harvest(
# logrotate.d entries) can still be attributed back to this service.
service_role_aliases[role] = set(hints) | set(pkgs) | {role}
for sp in _maybe_add_specific_paths(hints):
for sp in _maybe_add_specific_paths(hints, backend):
if not os.path.exists(sp):
continue
if sp in etc_owner_map:
@ -684,31 +756,13 @@ def harvest(
candidates.setdefault(sp, "custom_specific_path")
for pkg in sorted(pkgs):
conff = conffiles_by_pkg.get(pkg, {})
md5sums = read_pkg_md5sums(pkg)
for path in pkg_to_etc_paths.get(pkg, []):
etc_paths = pkg_to_etc_paths.get(pkg, [])
for path, reason in backend.modified_paths(pkg, etc_paths).items():
if not os.path.isfile(path) or os.path.islink(path):
continue
if path.startswith("/etc/apt/"):
if backend.is_pkg_config_path(path):
continue
if path in conff:
# Only capture conffiles when they differ from the package default.
try:
current = file_md5(path)
except OSError:
continue
if current != conff[path]:
candidates.setdefault(path, "modified_conffile")
continue
rel = path.lstrip("/")
baseline = md5sums.get(rel)
if baseline:
try:
current = file_md5(path)
except OSError:
continue
if current != baseline:
candidates.setdefault(path, "modified_packaged_file")
candidates.setdefault(path, reason)
# Capture custom/unowned files living under /etc/<name> for this service.
#
@ -847,18 +901,18 @@ def harvest(
# (useful when a timer triggers a service that isn't enabled).
pkgs: Set[str] = set()
if ti.fragment_path:
p = dpkg_owner(ti.fragment_path)
p = backend.owner_of_path(ti.fragment_path)
if p:
pkgs.add(p)
if ti.trigger_unit and ti.trigger_unit.endswith(".service"):
try:
ui = get_unit_info(ti.trigger_unit)
if ui.fragment_path:
p = dpkg_owner(ui.fragment_path)
p = backend.owner_of_path(ui.fragment_path)
if p:
pkgs.add(p)
for exe in ui.exec_paths:
p = dpkg_owner(exe)
p = backend.owner_of_path(exe)
if p:
pkgs.add(p)
except Exception: # nosec
@ -870,7 +924,7 @@ def harvest(
# -------------------------
# Manually installed package roles
# -------------------------
manual_pkgs = list_manual_packages()
manual_pkgs = backend.list_manual_packages()
# Avoid duplicate roles: if a manual package is already managed by any service role, skip its pkg_<name> role.
covered_by_services: Set[str] = set()
for s in service_snaps:
@ -893,41 +947,26 @@ def harvest(
for tpath in timer_extra_by_pkg.get(pkg, []):
candidates.setdefault(tpath, "related_timer")
conff = conffiles_by_pkg.get(pkg, {})
md5sums = read_pkg_md5sums(pkg)
for path in pkg_to_etc_paths.get(pkg, []):
etc_paths = pkg_to_etc_paths.get(pkg, [])
for path, reason in backend.modified_paths(pkg, etc_paths).items():
if not os.path.isfile(path) or os.path.islink(path):
continue
if path.startswith("/etc/apt/"):
if backend.is_pkg_config_path(path):
continue
if path in conff:
try:
current = file_md5(path)
except OSError:
continue
if current != conff[path]:
candidates.setdefault(path, "modified_conffile")
continue
rel = path.lstrip("/")
baseline = md5sums.get(rel)
if baseline:
try:
current = file_md5(path)
except OSError:
continue
if current != baseline:
candidates.setdefault(path, "modified_packaged_file")
candidates.setdefault(path, reason)
topdirs = _topdirs_for_package(pkg, pkg_to_etc_paths)
roots: List[str] = []
# Collect candidate directories plus backend-specific common files.
for td in sorted(topdirs):
if td in SHARED_ETC_TOPDIRS:
continue
if backend.is_pkg_config_path(f"/etc/{td}/") or backend.is_pkg_config_path(
f"/etc/{td}"
):
continue
roots.extend([f"/etc/{td}", f"/etc/{td}.d"])
roots.extend([f"/etc/default/{td}"])
roots.extend([f"/etc/init.d/{td}"])
roots.extend([f"/etc/sysctl.d/{td}.conf"])
roots.extend(_maybe_add_specific_paths(set(topdirs), backend))
# Capture any custom/unowned files under /etc/<topdir> for this
# manually-installed package. This may include runtime-generated
@ -1031,26 +1070,48 @@ def harvest(
)
# -------------------------
# apt_config role (APT configuration and keyrings)
# Package manager config role
# - Debian: apt_config
# - Fedora/RHEL-like: dnf_config
# -------------------------
apt_notes: List[str] = []
apt_excluded: List[ExcludedFile] = []
apt_managed: List[ManagedFile] = []
apt_role_name = "apt_config"
apt_role_seen = seen_by_role.setdefault(apt_role_name, set())
dnf_notes: List[str] = []
dnf_excluded: List[ExcludedFile] = []
dnf_managed: List[ManagedFile] = []
for path, reason in _iter_apt_capture_paths():
_capture_file(
bundle_dir=bundle_dir,
role_name=apt_role_name,
abs_path=path,
reason=reason,
policy=policy,
path_filter=path_filter,
managed_out=apt_managed,
excluded_out=apt_excluded,
seen_role=apt_role_seen,
)
apt_role_name = "apt_config"
dnf_role_name = "dnf_config"
if backend.name == "dpkg":
apt_role_seen = seen_by_role.setdefault(apt_role_name, set())
for path, reason in _iter_apt_capture_paths():
_capture_file(
bundle_dir=bundle_dir,
role_name=apt_role_name,
abs_path=path,
reason=reason,
policy=policy,
path_filter=path_filter,
managed_out=apt_managed,
excluded_out=apt_excluded,
seen_role=apt_role_seen,
)
elif backend.name == "rpm":
dnf_role_seen = seen_by_role.setdefault(dnf_role_name, set())
for path, reason in _iter_dnf_capture_paths():
_capture_file(
bundle_dir=bundle_dir,
role_name=dnf_role_name,
abs_path=path,
reason=reason,
policy=policy,
path_filter=path_filter,
managed_out=dnf_managed,
excluded_out=dnf_excluded,
seen_role=dnf_role_seen,
)
apt_config_snapshot = AptConfigSnapshot(
role_name=apt_role_name,
@ -1058,6 +1119,12 @@ def harvest(
excluded=apt_excluded,
notes=apt_notes,
)
dnf_config_snapshot = DnfConfigSnapshot(
role_name=dnf_role_name,
managed_files=dnf_managed,
excluded=dnf_excluded,
notes=dnf_notes,
)
# -------------------------
# etc_custom role (unowned /etc files not already attributed elsewhere)
@ -1079,6 +1146,8 @@ def harvest(
already.add(mf.path)
for mf in apt_managed:
already.add(mf.path)
for mf in dnf_managed:
already.add(mf.path)
# Maps for re-attributing shared snippets (cron.d/logrotate.d) to existing roles.
svc_by_role: Dict[str, ServiceSnapshot] = {s.role_name: s for s in service_snaps}
@ -1093,7 +1162,7 @@ def harvest(
for pkg in s.packages:
pkg_to_service_roles.setdefault(pkg, []).append(s.role_name)
# Alias -> role mapping used as a fallback when dpkg ownership is missing.
# Alias -> role mapping used as a fallback when package ownership is missing.
# Prefer service roles over package roles when both would match.
alias_ranked: Dict[str, tuple[int, str]] = {}
@ -1124,8 +1193,8 @@ def harvest(
per service.
Resolution order:
1) dpkg owner -> service role (if any service references the package)
2) dpkg owner -> package role (manual package role exists)
1) package owner -> service role (if any service references the package)
2) package owner -> package role (manual package role exists)
3) basename/stem alias match -> preferred role
"""
if path.startswith("/etc/logrotate.d/"):
@ -1147,7 +1216,7 @@ def harvest(
seen.add(c)
uniq.append(c)
pkg = dpkg_owner(path)
pkg = backend.owner_of_path(path)
if pkg:
svc_roles = sorted(set(pkg_to_service_roles.get(pkg, [])))
if svc_roles:
@ -1226,7 +1295,7 @@ def harvest(
for dirpath, _, filenames in os.walk("/etc"):
for fn in filenames:
path = os.path.join(dirpath, fn)
if path.startswith("/etc/apt/"):
if backend.is_pkg_config_path(path):
continue
if path in already:
continue
@ -1413,13 +1482,22 @@ def harvest(
)
state = {
"host": {"hostname": os.uname().nodename, "os": "debian"},
"enroll": {
"version": get_enroll_version(),
},
"host": {
"hostname": os.uname().nodename,
"os": platform.os_family,
"pkg_backend": backend.name,
"os_release": platform.os_release,
},
"users": asdict(users_snapshot),
"services": [asdict(s) for s in service_snaps],
"manual_packages": manual_pkgs,
"manual_packages_skipped": manual_pkgs_skipped,
"package_roles": [asdict(p) for p in pkg_snaps],
"apt_config": asdict(apt_config_snapshot),
"dnf_config": asdict(dnf_config_snapshot),
"etc_custom": asdict(etc_custom_snapshot),
"usr_local_custom": asdict(usr_local_custom_snapshot),
"extra_paths": asdict(extra_paths_snapshot),

View file

@ -43,6 +43,7 @@ DEFAULT_ALLOW_BINARY_GLOBS = [
"/usr/share/keyrings/*.gpg",
"/usr/share/keyrings/*.pgp",
"/usr/share/keyrings/*.asc",
"/etc/pki/rpm-gpg/*",
]
SENSITIVE_CONTENT_PATTERNS = [

View file

@ -166,6 +166,7 @@ def _write_playbook_all(path: str, roles: List[str]) -> None:
pb_lines = [
"---",
"- name: Apply all roles on all hosts",
" gather_facts: true",
" hosts: all",
" become: true",
" roles:",
@ -181,6 +182,7 @@ def _write_playbook_host(path: str, fqdn: str, roles: List[str]) -> None:
"---",
f"- name: Apply all roles on {fqdn}",
f" hosts: {fqdn}",
" gather_facts: true",
" become: true",
" roles:",
]
@ -468,6 +470,51 @@ def _render_generic_files_tasks(
"""
def _render_install_packages_tasks(role: str, var_prefix: str) -> str:
"""Render cross-distro package installation tasks.
We generate conditional tasks for apt/dnf/yum, falling back to the
generic `package` module. This keeps generated roles usable on both
Debian-like and RPM-like systems.
"""
return f"""# Generated by enroll
- name: Install packages for {role} (APT)
ansible.builtin.apt:
name: "{{{{ {var_prefix}_packages | default([]) }}}}"
state: present
update_cache: true
when:
- ({var_prefix}_packages | default([])) | length > 0
- ansible_facts.pkg_mgr | default('') == 'apt'
- name: Install packages for {role} (DNF5)
ansible.builtin.dnf5:
name: "{{{{ {var_prefix}_packages | default([]) }}}}"
state: present
when:
- ({var_prefix}_packages | default([])) | length > 0
- ansible_facts.pkg_mgr | default('') == 'dnf5'
- name: Install packages for {role} (DNF/YUM)
ansible.builtin.dnf:
name: "{{{{ {var_prefix}_packages | default([]) }}}}"
state: present
when:
- ({var_prefix}_packages | default([])) | length > 0
- ansible_facts.pkg_mgr | default('') in ['dnf', 'yum']
- name: Install packages for {role} (generic fallback)
ansible.builtin.package:
name: "{{{{ {var_prefix}_packages | default([]) }}}}"
state: present
when:
- ({var_prefix}_packages | default([])) | length > 0
- ansible_facts.pkg_mgr | default('') not in ['apt', 'dnf', 'dnf5', 'yum']
"""
def _prepare_bundle_dir(
bundle: str,
*,
@ -629,6 +676,7 @@ def _manifest_from_bundle_dir(
package_roles: List[Dict[str, Any]] = state.get("package_roles", [])
users_snapshot: Dict[str, Any] = state.get("users", {})
apt_config_snapshot: Dict[str, Any] = state.get("apt_config", {})
dnf_config_snapshot: Dict[str, Any] = state.get("dnf_config", {})
etc_custom_snapshot: Dict[str, Any] = state.get("etc_custom", {})
usr_local_custom_snapshot: Dict[str, Any] = state.get("usr_local_custom", {})
extra_paths_snapshot: Dict[str, Any] = state.get("extra_paths", {})
@ -664,6 +712,7 @@ def _manifest_from_bundle_dir(
manifested_users_roles: List[str] = []
manifested_apt_config_roles: List[str] = []
manifested_dnf_config_roles: List[str] = []
manifested_etc_custom_roles: List[str] = []
manifested_usr_local_custom_roles: List[str] = []
manifested_extra_paths_roles: List[str] = []
@ -1041,6 +1090,157 @@ APT configuration harvested from the system (sources, pinning, and keyrings).
manifested_apt_config_roles.append(role)
# -------------------------
# dnf_config role (DNF/YUM repos, config, and RPM GPG keys)
# -------------------------
if dnf_config_snapshot and dnf_config_snapshot.get("managed_files"):
role = dnf_config_snapshot.get("role_name", "dnf_config")
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
var_prefix = role
managed_files = dnf_config_snapshot.get("managed_files", [])
excluded = dnf_config_snapshot.get("excluded", [])
notes = dnf_config_snapshot.get("notes", [])
templated, jt_vars = _jinjify_managed_files(
bundle_dir,
role,
role_dir,
managed_files,
jt_exe=jt_exe,
jt_enabled=jt_enabled,
overwrite_templates=not site_mode,
)
if site_mode:
_copy_artifacts(
bundle_dir,
role,
_host_role_files_dir(out_dir, fqdn or "", role),
exclude_rels=templated,
)
else:
_copy_artifacts(
bundle_dir,
role,
os.path.join(role_dir, "files"),
exclude_rels=templated,
)
files_var = _build_managed_files_var(
managed_files,
templated,
notify_other=None,
notify_systemd=None,
)
jt_map = _yaml_load_mapping(jt_vars) if jt_vars.strip() else {}
vars_map: Dict[str, Any] = {f"{var_prefix}_managed_files": files_var}
vars_map = _merge_mappings_overwrite(vars_map, jt_map)
if site_mode:
_write_role_defaults(role_dir, {f"{var_prefix}_managed_files": []})
_write_hostvars(out_dir, fqdn or "", role, vars_map)
else:
_write_role_defaults(role_dir, vars_map)
tasks = "---\n" + _render_generic_files_tasks(
var_prefix, include_restart_notify=False
)
with open(
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(tasks.rstrip() + "\n")
with open(
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
) as f:
f.write("---\ndependencies: []\n")
# README: summarise repos and GPG key material
repo_paths: List[str] = []
key_paths: List[str] = []
repo_hosts: Set[str] = set()
url_re = re.compile(r"(?:https?|ftp)://([^/\s]+)", re.IGNORECASE)
file_url_re = re.compile(r"file://(/[^\s]+)")
for mf in managed_files:
p = str(mf.get("path") or "")
src_rel = str(mf.get("src_rel") or "")
if not p or not src_rel:
continue
if p.startswith("/etc/yum.repos.d/") and p.endswith(".repo"):
repo_paths.append(p)
art_path = os.path.join(bundle_dir, "artifacts", role, src_rel)
try:
with open(art_path, "r", encoding="utf-8", errors="replace") as rf:
for line in rf:
s = line.strip()
if not s or s.startswith("#") or s.startswith(";"):
continue
# Collect hostnames from URLs (baseurl, mirrorlist, metalink, gpgkey...)
for m in url_re.finditer(s):
repo_hosts.add(m.group(1))
# Collect local gpgkey file paths referenced as file:///...
for m in file_url_re.finditer(s):
key_paths.append(m.group(1))
except OSError:
pass # nosec
if p.startswith("/etc/pki/rpm-gpg/"):
key_paths.append(p)
repo_paths = sorted(set(repo_paths))
key_paths = sorted(set(key_paths))
repos = sorted(repo_hosts)
readme = (
"""# dnf_config
DNF/YUM configuration harvested from the system (repos, config files, and RPM GPG keys).
## Repository hosts
"""
+ ("\n".join([f"- {h}" for h in repos]) or "- (none)")
+ """\n
## Repo files
"""
+ ("\n".join([f"- {p}" for p in repo_paths]) or "- (none)")
+ """\n
## GPG keys
"""
+ ("\n".join([f"- {p}" for p in key_paths]) or "- (none)")
+ """\n
## Managed files
"""
+ (
"\n".join(
[f"- {mf.get('path')} ({mf.get('reason')})" for mf in managed_files]
)
or "- (none)"
)
+ """\n
## Excluded
"""
+ (
"\n".join([f"- {e.get('path')} ({e.get('reason')})" for e in excluded])
or "- (none)"
)
+ """\n
## Notes
"""
+ ("\n".join([f"- {n}" for n in notes]) or "- (none)")
+ """\n"""
)
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifested_dnf_config_roles.append(role)
# -------------------------
# etc_custom role (unowned /etc not already attributed)
# -------------------------
@ -1457,19 +1657,7 @@ User-requested extra file harvesting.
f.write(handlers)
task_parts: List[str] = []
task_parts.append(
f"""---
# Generated by enroll
- name: Install packages for {role}
ansible.builtin.apt:
name: "{{{{ {var_prefix}_packages | default([]) }}}}"
state: present
update_cache: true
when: ({var_prefix}_packages | default([])) | length > 0
"""
)
task_parts.append("---\n" + _render_install_packages_tasks(role, var_prefix))
task_parts.append(
_render_generic_files_tasks(var_prefix, include_restart_notify=True)
@ -1616,19 +1804,7 @@ Generated from `{unit}`.
f.write(handlers)
task_parts: List[str] = []
task_parts.append(
f"""---
# Generated by enroll
- name: Install packages for {role}
ansible.builtin.apt:
name: "{{{{ {var_prefix}_packages | default([]) }}}}"
state: present
update_cache: true
when: ({var_prefix}_packages | default([])) | length > 0
"""
)
task_parts.append("---\n" + _render_install_packages_tasks(role, var_prefix))
task_parts.append(
_render_generic_files_tasks(var_prefix, include_restart_notify=False)
)
@ -1667,6 +1843,7 @@ Generated for package `{pkg}`.
manifested_pkg_roles.append(role)
all_roles = (
manifested_apt_config_roles
+ manifested_dnf_config_roles
+ manifested_pkg_roles
+ manifested_service_roles
+ manifested_etc_custom_roles

261
enroll/platform.py Normal file
View file

@ -0,0 +1,261 @@
from __future__ import annotations
import shutil
from dataclasses import dataclass
from typing import Dict, List, Optional, Set, Tuple
from .fsutil import file_md5
def _read_os_release(path: str = "/etc/os-release") -> Dict[str, str]:
out: Dict[str, str] = {}
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
v = v.strip().strip('"')
out[k] = v
except OSError:
return {}
return out
@dataclass
class PlatformInfo:
os_family: str # debian|redhat|unknown
pkg_backend: str # dpkg|rpm|unknown
os_release: Dict[str, str]
def detect_platform() -> PlatformInfo:
"""Detect platform family and package backend.
Uses /etc/os-release when available, with a conservative fallback to
checking for dpkg/rpm binaries.
"""
osr = _read_os_release()
os_id = (osr.get("ID") or "").strip().lower()
likes = (osr.get("ID_LIKE") or "").strip().lower().split()
deb_ids = {"debian", "ubuntu", "linuxmint", "raspbian", "kali"}
rhel_ids = {
"fedora",
"rhel",
"centos",
"rocky",
"almalinux",
"ol",
"oracle",
"scientific",
}
if os_id in deb_ids or "debian" in likes:
return PlatformInfo(os_family="debian", pkg_backend="dpkg", os_release=osr)
if os_id in rhel_ids or any(
x in likes for x in ("rhel", "fedora", "centos", "redhat")
):
return PlatformInfo(os_family="redhat", pkg_backend="rpm", os_release=osr)
# Fallback heuristics.
if shutil.which("dpkg"):
return PlatformInfo(os_family="debian", pkg_backend="dpkg", os_release=osr)
if shutil.which("rpm"):
return PlatformInfo(os_family="redhat", pkg_backend="rpm", os_release=osr)
return PlatformInfo(os_family="unknown", pkg_backend="unknown", os_release=osr)
class PackageBackend:
"""Backend abstraction for package ownership, config detection, and manual package lists."""
name: str
pkg_config_prefixes: Tuple[str, ...]
def owner_of_path(self, path: str) -> Optional[str]: # pragma: no cover
raise NotImplementedError
def list_manual_packages(self) -> List[str]: # pragma: no cover
raise NotImplementedError
def build_etc_index(
self,
) -> Tuple[
Set[str], Dict[str, str], Dict[str, Set[str]], Dict[str, List[str]]
]: # pragma: no cover
raise NotImplementedError
def specific_paths_for_hints(self, hints: Set[str]) -> List[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]) -> Dict[str, str]:
"""Return a mapping of modified file paths -> reason label."""
return {}
class DpkgBackend(PackageBackend):
name = "dpkg"
pkg_config_prefixes = ("/etc/apt/",)
def __init__(self) -> None:
from .debian import parse_status_conffiles
self._conffiles_by_pkg = parse_status_conffiles()
def owner_of_path(self, path: str) -> Optional[str]:
from .debian import dpkg_owner
return dpkg_owner(path)
def list_manual_packages(self) -> List[str]:
from .debian import list_manual_packages
return list_manual_packages()
def build_etc_index(self):
from .debian import build_dpkg_etc_index
return build_dpkg_etc_index()
def specific_paths_for_hints(self, hints: Set[str]) -> List[str]:
paths: List[str] = []
for h in hints:
paths.extend(
[
f"/etc/default/{h}",
f"/etc/init.d/{h}",
f"/etc/sysctl.d/{h}.conf",
]
)
return paths
def modified_paths(self, pkg: str, etc_paths: List[str]) -> Dict[str, str]:
from .debian import read_pkg_md5sums
out: Dict[str, str] = {}
conff = self._conffiles_by_pkg.get(pkg, {})
md5sums = read_pkg_md5sums(pkg)
for path in etc_paths:
if not path.startswith("/etc/"):
continue
if self.is_pkg_config_path(path):
continue
if path in conff:
try:
current = file_md5(path)
except OSError:
continue
if current != conff[path]:
out[path] = "modified_conffile"
continue
rel = path.lstrip("/")
baseline = md5sums.get(rel)
if baseline:
try:
current = file_md5(path)
except OSError:
continue
if current != baseline:
out[path] = "modified_packaged_file"
return out
class RpmBackend(PackageBackend):
name = "rpm"
pkg_config_prefixes = (
"/etc/dnf/",
"/etc/yum/",
"/etc/yum.repos.d/",
"/etc/yum.conf",
)
def __init__(self) -> None:
self._modified_cache: Dict[str, Set[str]] = {}
self._config_cache: Dict[str, Set[str]] = {}
def owner_of_path(self, path: str) -> Optional[str]:
from .rpm import rpm_owner
return rpm_owner(path)
def list_manual_packages(self) -> List[str]:
from .rpm import list_manual_packages
return list_manual_packages()
def build_etc_index(self):
from .rpm import build_rpm_etc_index
return build_rpm_etc_index()
def specific_paths_for_hints(self, hints: Set[str]) -> List[str]:
paths: List[str] = []
for h in hints:
paths.extend(
[
f"/etc/sysconfig/{h}",
f"/etc/sysconfig/{h}.conf",
f"/etc/sysctl.d/{h}.conf",
]
)
return paths
def _config_files(self, pkg: str) -> Set[str]:
if pkg in self._config_cache:
return self._config_cache[pkg]
from .rpm import rpm_config_files
s = rpm_config_files(pkg)
self._config_cache[pkg] = s
return s
def _modified_files(self, pkg: str) -> Set[str]:
if pkg in self._modified_cache:
return self._modified_cache[pkg]
from .rpm import rpm_modified_files
s = rpm_modified_files(pkg)
self._modified_cache[pkg] = s
return s
def modified_paths(self, pkg: str, etc_paths: List[str]) -> Dict[str, str]:
out: Dict[str, str] = {}
modified = self._modified_files(pkg)
if not modified:
return out
config = self._config_files(pkg)
for path in etc_paths:
if not path.startswith("/etc/"):
continue
if self.is_pkg_config_path(path):
continue
if path not in modified:
continue
out[path] = (
"modified_conffile" if path in config else "modified_packaged_file"
)
return out
def get_backend(info: Optional[PlatformInfo] = None) -> PackageBackend:
info = info or detect_platform()
if info.pkg_backend == "dpkg":
return DpkgBackend()
if info.pkg_backend == "rpm":
return RpmBackend()
# Unknown: be conservative and use an rpm backend if rpm exists, otherwise dpkg.
if shutil.which("rpm"):
return RpmBackend()
return DpkgBackend()

266
enroll/rpm.py Normal file
View file

@ -0,0 +1,266 @@
from __future__ import annotations
import os
import re
import shutil
import subprocess # nosec
from typing import Dict, List, Optional, Set, Tuple
def _run(
cmd: list[str], *, allow_fail: bool = False, merge_err: bool = False
) -> tuple[int, str]:
"""Run a command and return (rc, stdout).
If merge_err is True, stderr is merged into stdout to preserve ordering.
"""
p = subprocess.run(
cmd,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=(subprocess.STDOUT if merge_err else subprocess.PIPE),
) # nosec
out = p.stdout or ""
if (not allow_fail) and p.returncode != 0:
err = "" if merge_err else (p.stderr or "")
raise RuntimeError(f"Command failed: {cmd}\n{err}{out}")
return p.returncode, out
def rpm_owner(path: str) -> Optional[str]:
"""Return owning package name for a path, or None if unowned."""
if not path:
return None
rc, out = _run(
["rpm", "-qf", "--qf", "%{NAME}\n", path], allow_fail=True, merge_err=True
)
if rc != 0:
return None
for line in out.splitlines():
line = line.strip()
if not line:
continue
if "is not owned" in line:
return None
# With --qf we expect just the package name.
if re.match(r"^[A-Za-z0-9_.+:-]+$", line):
# Strip any accidental epoch/name-version-release output.
return line.split(":", 1)[-1].strip() if line else None
return None
_ARCH_SUFFIXES = {
"noarch",
"x86_64",
"i686",
"aarch64",
"armv7hl",
"ppc64le",
"s390x",
"riscv64",
}
def _strip_arch(token: str) -> str:
"""Strip a trailing .ARCH from a yum/dnf package token."""
t = token.strip()
if "." not in t:
return t
head, tail = t.rsplit(".", 1)
if tail in _ARCH_SUFFIXES:
return head
return t
def list_manual_packages() -> List[str]:
"""Return packages considered "user-installed" on RPM-based systems.
Best-effort:
1) dnf repoquery --userinstalled
2) dnf history userinstalled
3) yum history userinstalled
If none are available, returns an empty list.
"""
def _dedupe(pkgs: List[str]) -> List[str]:
return sorted({p for p in (pkgs or []) if p})
if shutil.which("dnf"):
# Prefer a machine-friendly output.
for cmd in (
["dnf", "-q", "repoquery", "--userinstalled", "--qf", "%{name}\n"],
["dnf", "-q", "repoquery", "--userinstalled"],
):
rc, out = _run(cmd, allow_fail=True, merge_err=True)
if rc == 0 and out.strip():
pkgs = []
for line in out.splitlines():
line = line.strip()
if not line or line.startswith("Loaded plugins"):
continue
pkgs.append(_strip_arch(line.split()[0]))
if pkgs:
return _dedupe(pkgs)
# Fallback: human-oriented output.
rc, out = _run(
["dnf", "-q", "history", "userinstalled"], allow_fail=True, merge_err=True
)
if rc == 0 and out.strip():
pkgs = []
for line in out.splitlines():
line = line.strip()
if not line or line.startswith("Installed") or line.startswith("Last"):
continue
# Often: "vim-enhanced.x86_64"
tok = line.split()[0]
pkgs.append(_strip_arch(tok))
if pkgs:
return _dedupe(pkgs)
if shutil.which("yum"):
rc, out = _run(
["yum", "-q", "history", "userinstalled"], allow_fail=True, merge_err=True
)
if rc == 0 and out.strip():
pkgs = []
for line in out.splitlines():
line = line.strip()
if (
not line
or line.startswith("Installed")
or line.startswith("Loaded")
):
continue
tok = line.split()[0]
pkgs.append(_strip_arch(tok))
if pkgs:
return _dedupe(pkgs)
return []
def _walk_etc_files() -> List[str]:
out: List[str] = []
for dirpath, _, filenames in os.walk("/etc"):
for fn in filenames:
p = os.path.join(dirpath, fn)
if os.path.islink(p) or not os.path.isfile(p):
continue
out.append(p)
return out
def build_rpm_etc_index() -> (
Tuple[Set[str], Dict[str, str], Dict[str, Set[str]], Dict[str, List[str]]]
):
"""Best-effort equivalent of build_dpkg_etc_index for RPM systems.
This builds indexes by walking the live /etc tree and querying RPM ownership
for each file.
Returns:
owned_etc_paths: set of /etc paths owned by rpm
etc_owner_map: /etc/path -> pkg
topdir_to_pkgs: "nginx" -> {"nginx", ...} based on /etc/<topdir>/...
pkg_to_etc_paths: pkg -> list of owned /etc paths
"""
owned: Set[str] = set()
owner: Dict[str, str] = {}
topdir_to_pkgs: Dict[str, Set[str]] = {}
pkg_to_etc: Dict[str, List[str]] = {}
paths = _walk_etc_files()
# Query in chunks to avoid excessive process spawns.
chunk_size = 250
not_owned_re = re.compile(
r"^file\s+(?P<path>.+?)\s+is\s+not\s+owned\s+by\s+any\s+package", re.IGNORECASE
)
for i in range(0, len(paths), chunk_size):
chunk = paths[i : i + chunk_size]
rc, out = _run(
["rpm", "-qf", "--qf", "%{NAME}\n", *chunk],
allow_fail=True,
merge_err=True,
)
lines = [ln.strip() for ln in out.splitlines() if ln.strip()]
# Heuristic: rpm prints one output line per input path. If that isn't
# true (warnings/errors), fall back to per-file queries for this chunk.
if len(lines) != len(chunk):
for p in chunk:
pkg = rpm_owner(p)
if not pkg:
continue
owned.add(p)
owner.setdefault(p, pkg)
pkg_to_etc.setdefault(pkg, []).append(p)
parts = p.split("/", 3)
if len(parts) >= 3 and parts[2]:
topdir_to_pkgs.setdefault(parts[2], set()).add(pkg)
continue
for pth, line in zip(chunk, lines):
if not line:
continue
if not_owned_re.match(line) or "is not owned" in line:
continue
pkg = line.split()[0].strip()
if not pkg:
continue
owned.add(pth)
owner.setdefault(pth, pkg)
pkg_to_etc.setdefault(pkg, []).append(pth)
parts = pth.split("/", 3)
if len(parts) >= 3 and parts[2]:
topdir_to_pkgs.setdefault(parts[2], set()).add(pkg)
for k, v in list(pkg_to_etc.items()):
pkg_to_etc[k] = sorted(set(v))
return owned, owner, topdir_to_pkgs, pkg_to_etc
def rpm_config_files(pkg: str) -> Set[str]:
"""Return config files for a package (rpm -qc)."""
rc, out = _run(["rpm", "-qc", pkg], allow_fail=True, merge_err=True)
if rc != 0:
return set()
files: Set[str] = set()
for line in out.splitlines():
line = line.strip()
if line.startswith("/"):
files.add(line)
return files
def rpm_modified_files(pkg: str) -> Set[str]:
"""Return files reported as modified by rpm verification (rpm -V).
rpm -V only prints lines for differences/missing files.
"""
rc, out = _run(["rpm", "-V", pkg], allow_fail=True, merge_err=True)
# rc is non-zero when there are differences; we still want the output.
files: Set[str] = set()
for raw in out.splitlines():
line = raw.strip()
if not line:
continue
# Typical forms:
# S.5....T. c /etc/foo.conf
# missing /etc/bar
m = re.search(r"\s(/\S+)$", line)
if m:
files.add(m.group(1))
continue
if line.startswith("missing"):
parts = line.split()
if parts and parts[-1].startswith("/"):
files.add(parts[-1])
return files