diff --git a/enroll/manifest.py b/enroll/manifest.py index 8b4008b..bc629bb 100644 --- a/enroll/manifest.py +++ b/enroll/manifest.py @@ -390,9 +390,7 @@ def _render_generic_files_tasks( # Using first_found makes roles work in both modes: # - site-mode: inventory/host_vars///.files/... # - non-site: roles//files/... - return f""" - -- name: Deploy any systemd unit files (templates) + return f"""- name: Deploy any systemd unit files (templates) ansible.builtin.template: src: "{{{{ item.src_rel }}}}.j2" dest: "{{{{ item.dest }}}}" @@ -475,9 +473,7 @@ def _render_install_packages_tasks(role: str, var_prefix: str) -> str: generic `package` module. This keeps generated roles usable on both Debian-like and RPM-like systems. """ - return f""" - -- name: Install packages for {role} (APT) + return f"""- name: Install packages for {role} (APT) ansible.builtin.apt: name: "{{{{ {var_prefix}_packages | default([]) }}}}" state: present @@ -995,7 +991,7 @@ Generated non-system user accounts and SSH public material. else: _write_role_defaults(role_dir, vars_map) - tasks = """---\n""" + _render_generic_files_tasks( + tasks = "---\n" + _render_generic_files_tasks( var_prefix, include_restart_notify=False ) with open( @@ -1297,7 +1293,7 @@ DNF/YUM configuration harvested from the system (repos, config files, and RPM GP else: _write_role_defaults(role_dir, vars_map) - tasks = """---\n""" + _render_generic_files_tasks( + tasks = "---\n" + _render_generic_files_tasks( var_prefix, include_restart_notify=False ) with open( @@ -1663,8 +1659,7 @@ User-requested extra file harvesting. ) task_parts.append( - f""" -- name: Probe whether systemd unit exists and is manageable + f"""- name: Probe whether systemd unit exists and is manageable ansible.builtin.systemd: name: "{{{{ {var_prefix}_unit_name }}}}" check_mode: true diff --git a/enroll/rpm.py b/enroll/rpm.py index 9e2892f..0314670 100644 --- a/enroll/rpm.py +++ b/enroll/rpm.py @@ -104,7 +104,7 @@ def list_manual_packages() -> List[str]: if pkgs: return _dedupe(pkgs) - # Fallback: human-oriented output. + # Fallback rc, out = _run( ["dnf", "-q", "history", "userinstalled"], allow_fail=True, merge_err=True ) diff --git a/tests/test_diff_usr_local_custom.py b/tests/test_diff_usr_local_custom.py index 88d594f..28ec57c 100644 --- a/tests/test_diff_usr_local_custom.py +++ b/tests/test_diff_usr_local_custom.py @@ -18,65 +18,106 @@ def test_diff_includes_usr_local_custom_files(tmp_path: Path): new = tmp_path / "new" old_state = { - "host": {"hostname": "h1", "os": "debian"}, - "users": { - "role_name": "users", - "users": [], - "managed_files": [], - "excluded": [], - "notes": [], - }, - "services": [], - "package_roles": [], - "manual_packages": ["curl"], - "manual_packages_skipped": [], - "etc_custom": { - "role_name": "etc_custom", - "managed_files": [], - "excluded": [], - "notes": [], - }, - "usr_local_custom": { - "role_name": "usr_local_custom", - "managed_files": [ - { - "path": "/usr/local/etc/myapp.conf", - "src_rel": "usr/local/etc/myapp.conf", - "owner": "root", - "group": "root", - "mode": "0644", - "reason": "usr_local_etc_custom", + "schema_version": 3, + "host": {"hostname": "h1", "os": "debian", "pkg_backend": "dpkg"}, + "inventory": { + "packages": { + "curl": { + "version": "1.0", + "arches": [], + "installations": [{"version": "1.0", "arch": "amd64"}], + "observed_via": [{"kind": "user_installed"}], + "roles": [], } - ], - "excluded": [], - "notes": [], + } + }, + "roles": { + "users": { + "role_name": "users", + "users": [], + "managed_files": [], + "excluded": [], + "notes": [], + }, + "services": [], + "packages": [], + "apt_config": { + "role_name": "apt_config", + "managed_files": [], + "excluded": [], + "notes": [], + }, + "etc_custom": { + "role_name": "etc_custom", + "managed_files": [], + "excluded": [], + "notes": [], + }, + "usr_local_custom": { + "role_name": "usr_local_custom", + "managed_files": [ + { + "path": "/usr/local/etc/myapp.conf", + "src_rel": "usr/local/etc/myapp.conf", + "owner": "root", + "group": "root", + "mode": "0644", + "reason": "usr_local_etc_custom", + } + ], + "excluded": [], + "notes": [], + }, + "extra_paths": { + "role_name": "extra_paths", + "include_patterns": [], + "exclude_patterns": [], + "managed_files": [], + "excluded": [], + "notes": [], + }, }, } + new_state = { **old_state, - "manual_packages": ["curl", "htop"], - "usr_local_custom": { - "role_name": "usr_local_custom", - "managed_files": [ - { - "path": "/usr/local/etc/myapp.conf", - "src_rel": "usr/local/etc/myapp.conf", - "owner": "root", - "group": "root", - "mode": "0644", - "reason": "usr_local_etc_custom", + "inventory": { + "packages": { + **old_state["inventory"]["packages"], + "htop": { + "version": "3.0", + "arches": [], + "installations": [{"version": "3.0", "arch": "amd64"}], + "observed_via": [{"kind": "user_installed"}], + "roles": [], }, - { - "path": "/usr/local/bin/myscript", - "src_rel": "usr/local/bin/myscript", - "owner": "root", - "group": "root", - "mode": "0755", - "reason": "usr_local_bin_script", - }, - ], - "excluded": [], - "notes": [], + } + }, + "roles": { + **old_state["roles"], + "usr_local_custom": { + "role_name": "usr_local_custom", + "managed_files": [ + { + "path": "/usr/local/etc/myapp.conf", + "src_rel": "usr/local/etc/myapp.conf", + "owner": "root", + "group": "root", + "mode": "0644", + "reason": "usr_local_etc_custom", + }, + { + "path": "/usr/local/bin/myscript", + "src_rel": "usr/local/bin/myscript", + "owner": "root", + "group": "root", + "mode": "0755", + "reason": "usr_local_bin_script", + }, + ], + "excluded": [], + "notes": [], + }, }, } diff --git a/tests/test_harvest.py b/tests/test_harvest.py index a0d22ec..1b884aa 100644 --- a/tests/test_harvest.py +++ b/tests/test_harvest.py @@ -30,6 +30,7 @@ class FakeBackend: owner_fn, modified_by_pkg: dict[str, dict[str, str]] | None = None, pkg_config_prefixes: tuple[str, ...] = ("/etc/apt/",), + installed: dict[str, list[dict[str, str]]] | None = None, ): self.name = name self.pkg_config_prefixes = pkg_config_prefixes @@ -40,6 +41,7 @@ class FakeBackend: self._manual = manual_pkgs self._owner_fn = owner_fn self._modified_by_pkg = modified_by_pkg or {} + self._installed = installed or {} def build_etc_index(self): return ( @@ -55,6 +57,14 @@ class FakeBackend: def list_manual_packages(self): return list(self._manual) + def installed_packages(self): + """Return mapping package -> installations. + + The real backends return: + {"pkg": [{"version": "...", "arch": "..."}, ...]} + """ + return dict(self._installed) + def specific_paths_for_hints(self, hints: set[str]): return [] @@ -214,26 +224,36 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom( state_path = h.harvest(str(bundle), policy=AllowAllPolicy()) st = json.loads(Path(state_path).read_text(encoding="utf-8")) - assert "openvpn" in st["manual_packages"] - assert "curl" in st["manual_packages"] - assert "openvpn" in st["manual_packages_skipped"] - assert all(pr["package"] != "openvpn" for pr in st["package_roles"]) - assert any(pr["package"] == "curl" for pr in st["package_roles"]) + inv = st["inventory"]["packages"] + assert "openvpn" in inv + assert "curl" in inv + + # openvpn is managed by the service role, so it should NOT appear as a package role. + pkg_roles = st["roles"]["packages"] + assert all(pr["package"] != "openvpn" for pr in pkg_roles) + assert any(pr["package"] == "curl" for pr in pkg_roles) + + # Inventory provenance: openvpn should be observed via systemd unit. + openvpn_obs = inv["openvpn"]["observed_via"] + assert any( + o.get("kind") == "systemd_unit" and o.get("ref") == "openvpn.service" + for o in openvpn_obs + ) # Service role captured modified conffile - svc = st["services"][0] + svc = st["roles"]["services"][0] assert svc["unit"] == "openvpn.service" assert "openvpn" in svc["packages"] assert any(mf["path"] == "/etc/openvpn/server.conf" for mf in svc["managed_files"]) # Unowned /etc/default/keyboard is attributed to etc_custom only - etc_custom = st["etc_custom"] + etc_custom = st["roles"]["etc_custom"] assert any( mf["path"] == "/etc/default/keyboard" for mf in etc_custom["managed_files"] ) # /usr/local content is attributed to usr_local_custom - ul = st["usr_local_custom"] + ul = st["roles"]["usr_local_custom"] assert any(mf["path"] == "/usr/local/etc/myapp.conf" for mf in ul["managed_files"]) assert any(mf["path"] == "/usr/local/bin/myscript" for mf in ul["managed_files"]) assert all(mf["path"] != "/usr/local/bin/readme.txt" for mf in ul["managed_files"]) @@ -338,10 +358,12 @@ def test_shared_cron_snippet_prefers_matching_role_over_lexicographic( st = json.loads(Path(state_path).read_text(encoding="utf-8")) # Cron snippet should end up attached to the ntpsec role, not apparmor. - svc_ntpsec = next(s for s in st["services"] if s["role_name"] == "ntpsec") + svc_ntpsec = next(s for s in st["roles"]["services"] if s["role_name"] == "ntpsec") assert any(mf["path"] == "/etc/cron.d/ntpsec" for mf in svc_ntpsec["managed_files"]) - svc_apparmor = next(s for s in st["services"] if s["role_name"] == "apparmor") + svc_apparmor = next( + s for s in st["roles"]["services"] if s["role_name"] == "apparmor" + ) assert all( mf["path"] != "/etc/cron.d/ntpsec" for mf in svc_apparmor["managed_files"] ) diff --git a/tests/test_jinjaturtle.py b/tests/test_jinjaturtle.py index 68bb04c..c0447b1 100644 --- a/tests/test_jinjaturtle.py +++ b/tests/test_jinjaturtle.py @@ -24,44 +24,78 @@ def test_manifest_uses_jinjaturtle_templates_and_does_not_copy_raw( ) state = { - "host": {"hostname": "test", "os": "debian"}, - "users": { - "role_name": "users", - "users": [], - "managed_files": [], - "excluded": [], - "notes": [], + "schema_version": 3, + "host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"}, + "inventory": { + "packages": { + "foo": { + "version": "1.0", + "arches": [], + "installations": [{"version": "1.0", "arch": "amd64"}], + "observed_via": [{"kind": "systemd_unit", "ref": "foo.service"}], + "roles": ["foo"], + } + } }, - "etc_custom": { - "role_name": "etc_custom", - "managed_files": [], - "excluded": [], - "notes": [], - }, - "services": [ - { - "unit": "foo.service", - "role_name": "foo", - "packages": ["foo"], - "active_state": "inactive", - "sub_state": "dead", - "unit_file_state": "disabled", - "condition_result": "no", - "managed_files": [ - { - "path": "/etc/foo.ini", - "src_rel": "etc/foo.ini", - "owner": "root", - "group": "root", - "mode": "0644", - "reason": "modified_conffile", - } - ], + "roles": { + "users": { + "role_name": "users", + "users": [], + "managed_files": [], "excluded": [], "notes": [], - } - ], - "package_roles": [], + }, + "services": [ + { + "unit": "foo.service", + "role_name": "foo", + "packages": ["foo"], + "active_state": "inactive", + "sub_state": "dead", + "unit_file_state": "disabled", + "condition_result": "no", + "managed_files": [ + { + "path": "/etc/foo.ini", + "src_rel": "etc/foo.ini", + "owner": "root", + "group": "root", + "mode": "0644", + "reason": "modified_conffile", + } + ], + "excluded": [], + "notes": [], + } + ], + "packages": [], + "apt_config": { + "role_name": "apt_config", + "managed_files": [], + "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) diff --git a/tests/test_manifest.py b/tests/test_manifest.py index cbfc208..fec9cc3 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -13,95 +13,136 @@ def test_manifest_writes_roles_and_playbook_with_clean_when(tmp_path: Path): ) state = { - "host": {"hostname": "test", "os": "debian"}, - "users": { - "role_name": "users", - "users": [ + "schema_version": 3, + "host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"}, + "inventory": { + "packages": { + "foo": { + "version": "1.0", + "arches": [], + "installations": [{"version": "1.0", "arch": "amd64"}], + "observed_via": [{"kind": "systemd_unit", "ref": "foo.service"}], + "roles": ["foo"], + }, + "curl": { + "version": "8.0", + "arches": [], + "installations": [{"version": "8.0", "arch": "amd64"}], + "observed_via": [{"kind": "package_role", "ref": "curl"}], + "roles": ["curl"], + }, + } + }, + "roles": { + "users": { + "role_name": "users", + "users": [ + { + "name": "alice", + "uid": 1000, + "gid": 1000, + "gecos": "Alice", + "home": "/home/alice", + "shell": "/bin/bash", + "primary_group": "alice", + "supplementary_groups": ["docker", "qubes"], + } + ], + "managed_files": [], + "excluded": [], + "notes": [], + }, + "services": [ { - "name": "alice", - "uid": 1000, - "gid": 1000, - "gecos": "Alice", - "home": "/home/alice", - "shell": "/bin/bash", - "primary_group": "alice", - "supplementary_groups": ["docker", "qubes"], + "unit": "foo.service", + "role_name": "foo", + "packages": ["foo"], + "active_state": "inactive", + "sub_state": "dead", + "unit_file_state": "enabled", + "condition_result": "no", + "managed_files": [ + { + "path": "/etc/foo.conf", + "src_rel": "etc/foo.conf", + "owner": "root", + "group": "root", + "mode": "0644", + "reason": "modified_conffile", + } + ], + "excluded": [], + "notes": [], } ], - "managed_files": [], - "excluded": [], - "notes": [], - }, - "etc_custom": { - "role_name": "etc_custom", - "managed_files": [ + "packages": [ { - "path": "/etc/default/keyboard", - "src_rel": "etc/default/keyboard", - "owner": "root", - "group": "root", - "mode": "0644", - "reason": "custom_unowned", + "package": "curl", + "role_name": "curl", + "managed_files": [], + "excluded": [], + "notes": [], } ], - "excluded": [], - "notes": [], - }, - "usr_local_custom": { - "role_name": "usr_local_custom", - "managed_files": [ - { - "path": "/usr/local/etc/myapp.conf", - "src_rel": "usr/local/etc/myapp.conf", - "owner": "root", - "group": "root", - "mode": "0644", - "reason": "usr_local_etc_custom", - }, - { - "path": "/usr/local/bin/myscript", - "src_rel": "usr/local/bin/myscript", - "owner": "root", - "group": "root", - "mode": "0755", - "reason": "usr_local_bin_script", - }, - ], - "excluded": [], - "notes": [], - }, - "services": [ - { - "unit": "foo.service", - "role_name": "foo", - "packages": ["foo"], - "active_state": "inactive", - "sub_state": "dead", - "unit_file_state": "enabled", - "condition_result": "no", + "apt_config": { + "role_name": "apt_config", + "managed_files": [], + "excluded": [], + "notes": [], + }, + "dnf_config": { + "role_name": "dnf_config", + "managed_files": [], + "excluded": [], + "notes": [], + }, + "etc_custom": { + "role_name": "etc_custom", "managed_files": [ { - "path": "/etc/foo.conf", - "src_rel": "etc/foo.conf", + "path": "/etc/default/keyboard", + "src_rel": "etc/default/keyboard", "owner": "root", "group": "root", "mode": "0644", - "reason": "modified_conffile", + "reason": "custom_unowned", } ], "excluded": [], "notes": [], - } - ], - "package_roles": [ - { - "package": "curl", - "role_name": "curl", + }, + "usr_local_custom": { + "role_name": "usr_local_custom", + "managed_files": [ + { + "path": "/usr/local/etc/myapp.conf", + "src_rel": "usr/local/etc/myapp.conf", + "owner": "root", + "group": "root", + "mode": "0644", + "reason": "usr_local_etc_custom", + }, + { + "path": "/usr/local/bin/myscript", + "src_rel": "usr/local/bin/myscript", + "owner": "root", + "group": "root", + "mode": "0755", + "reason": "usr_local_bin_script", + }, + ], + "excluded": [], + "notes": [], + }, + "extra_paths": { + "role_name": "extra_paths", + "include_patterns": [], + "exclude_patterns": [], "managed_files": [], "excluded": [], "notes": [], - } - ], + }, + }, } bundle.mkdir(parents=True, exist_ok=True) @@ -189,68 +230,102 @@ def test_manifest_site_mode_creates_host_inventory_and_raw_files(tmp_path: Path) ) state = { - "host": {"hostname": "test", "os": "debian"}, - "users": { - "role_name": "users", - "users": [], - "managed_files": [], - "excluded": [], - "notes": [], + "schema_version": 3, + "host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"}, + "inventory": { + "packages": { + "foo": { + "version": "1.0", + "arches": [], + "installations": [{"version": "1.0", "arch": "amd64"}], + "observed_via": [{"kind": "systemd_unit", "ref": "foo.service"}], + "roles": ["foo"], + } + } }, - "etc_custom": { - "role_name": "etc_custom", - "managed_files": [ + "roles": { + "users": { + "role_name": "users", + "users": [], + "managed_files": [], + "excluded": [], + "notes": [], + }, + "services": [ { - "path": "/etc/default/keyboard", - "src_rel": "etc/default/keyboard", - "owner": "root", - "group": "root", - "mode": "0644", - "reason": "custom_unowned", + "unit": "foo.service", + "role_name": "foo", + "packages": ["foo"], + "active_state": "active", + "sub_state": "running", + "unit_file_state": "enabled", + "condition_result": "yes", + "managed_files": [ + { + "path": "/etc/foo.conf", + "src_rel": "etc/foo.conf", + "owner": "root", + "group": "root", + "mode": "0644", + "reason": "modified_conffile", + } + ], + "excluded": [], + "notes": [], } ], - "excluded": [], - "notes": [], - }, - "usr_local_custom": { - "role_name": "usr_local_custom", - "managed_files": [ - { - "path": "/usr/local/etc/myapp.conf", - "src_rel": "usr/local/etc/myapp.conf", - "owner": "root", - "group": "root", - "mode": "0644", - "reason": "usr_local_etc_custom", - } - ], - "excluded": [], - "notes": [], - }, - "services": [ - { - "unit": "foo.service", - "role_name": "foo", - "packages": ["foo"], - "active_state": "active", - "sub_state": "running", - "unit_file_state": "enabled", - "condition_result": "yes", + "packages": [], + "apt_config": { + "role_name": "apt_config", + "managed_files": [], + "excluded": [], + "notes": [], + }, + "dnf_config": { + "role_name": "dnf_config", + "managed_files": [], + "excluded": [], + "notes": [], + }, + "etc_custom": { + "role_name": "etc_custom", "managed_files": [ { - "path": "/etc/foo.conf", - "src_rel": "etc/foo.conf", + "path": "/etc/default/keyboard", + "src_rel": "etc/default/keyboard", "owner": "root", "group": "root", "mode": "0644", - "reason": "modified_conffile", + "reason": "custom_unowned", } ], "excluded": [], "notes": [], - } - ], - "package_roles": [], + }, + "usr_local_custom": { + "role_name": "usr_local_custom", + "managed_files": [ + { + "path": "/usr/local/etc/myapp.conf", + "src_rel": "usr/local/etc/myapp.conf", + "owner": "root", + "group": "root", + "mode": "0644", + "reason": "usr_local_etc_custom", + } + ], + "excluded": [], + "notes": [], + }, + "extra_paths": { + "role_name": "extra_paths", + "include_patterns": [], + "exclude_patterns": [], + "managed_files": [], + "excluded": [], + "notes": [], + }, + }, } bundle.mkdir(parents=True, exist_ok=True) @@ -337,58 +412,70 @@ def test_manifest_includes_dnf_config_role_when_present(tmp_path: Path): ) state = { + "schema_version": 3, "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", + "inventory": { + "packages": { + "dnf": { + "version": "4.0", + "arches": [], + "installations": [{"version": "4.0", "arch": "x86_64"}], + "observed_via": [{"kind": "dnf_config"}], + "roles": [], } - ], - "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": [], + "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": [ + { + "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": [], + }, }, }