Many tweaks

This commit is contained in:
Miguel Jacq 2025-12-15 11:04:54 +11:00
parent 5398ad123c
commit 227be6dd51
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
20 changed files with 1350 additions and 174 deletions

7
tests/conftest.py Normal file
View file

@ -0,0 +1,7 @@
import sys
from pathlib import Path
# Ensure repository root is on sys.path so `import enroll` resolves to the local package.
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

77
tests/test_cli.py Normal file
View file

@ -0,0 +1,77 @@
import sys
import enroll.cli as cli
def test_cli_harvest_subcommand_calls_harvest(monkeypatch, capsys, tmp_path):
called = {}
def fake_harvest(out: str):
called["out"] = out
return str(tmp_path / "state.json")
monkeypatch.setattr(cli, "harvest", fake_harvest)
monkeypatch.setattr(sys, "argv", ["enroll", "harvest", "--out", str(tmp_path)])
cli.main()
assert called["out"] == str(tmp_path)
captured = capsys.readouterr()
assert str(tmp_path / "state.json") in captured.out
def test_cli_manifest_subcommand_calls_manifest(monkeypatch, tmp_path):
called = {}
def fake_manifest(harvest_dir: str, out_dir: str):
called["harvest"] = harvest_dir
called["out"] = out_dir
monkeypatch.setattr(cli, "manifest", fake_manifest)
monkeypatch.setattr(
sys,
"argv",
[
"enroll",
"manifest",
"--harvest",
str(tmp_path / "bundle"),
"--out",
str(tmp_path / "ansible"),
],
)
cli.main()
assert called["harvest"] == str(tmp_path / "bundle")
assert called["out"] == str(tmp_path / "ansible")
def test_cli_enroll_subcommand_runs_harvest_then_manifest(monkeypatch, tmp_path):
calls = []
def fake_harvest(bundle_dir: str):
calls.append(("harvest", bundle_dir))
return str(tmp_path / "bundle" / "state.json")
def fake_manifest(bundle_dir: str, out_dir: str):
calls.append(("manifest", bundle_dir, out_dir))
monkeypatch.setattr(cli, "harvest", fake_harvest)
monkeypatch.setattr(cli, "manifest", fake_manifest)
monkeypatch.setattr(
sys,
"argv",
[
"enroll",
"enroll",
"--harvest",
str(tmp_path / "bundle"),
"--out",
str(tmp_path / "ansible"),
],
)
cli.main()
assert calls == [
("harvest", str(tmp_path / "bundle")),
("manifest", str(tmp_path / "bundle"), str(tmp_path / "ansible")),
]

141
tests/test_harvest.py Normal file
View file

@ -0,0 +1,141 @@
import json
from pathlib import Path
import enroll.harvest as h
from enroll.systemd import UnitInfo
class AllowAllPolicy:
def deny_reason(self, path: str):
return None
def test_harvest_dedup_manual_packages_and_builds_etc_custom(
monkeypatch, tmp_path: Path
):
bundle = tmp_path / "bundle"
import os
real_isfile = os.path.isfile
real_isdir = os.path.isdir
real_exists = os.path.exists
real_islink = os.path.islink
# Fake filesystem: two /etc files exist, only one is dpkg-owned.
files = {
"/etc/openvpn/server.conf": b"server",
"/etc/default/keyboard": b"kbd",
}
dirs = {"/etc", "/etc/openvpn", "/etc/default"}
def fake_isfile(p: str) -> bool:
if p.startswith("/etc/") or p == "/etc":
return p in files
return real_isfile(p)
def fake_isdir(p: str) -> bool:
if p.startswith("/etc"):
return p in dirs
return real_isdir(p)
def fake_islink(p: str) -> bool:
if p.startswith("/etc"):
return False
return real_islink(p)
def fake_exists(p: str) -> bool:
if p.startswith("/etc"):
return p in files or p in dirs
return real_exists(p)
def fake_walk(root: str):
if root == "/etc":
yield ("/etc/openvpn", [], ["server.conf"])
yield ("/etc/default", [], ["keyboard"])
elif root == "/etc/openvpn":
yield ("/etc/openvpn", [], ["server.conf"])
elif root == "/etc/default":
yield ("/etc/default", [], ["keyboard"])
else:
yield (root, [], [])
monkeypatch.setattr(h.os.path, "isfile", fake_isfile)
monkeypatch.setattr(h.os.path, "isdir", fake_isdir)
monkeypatch.setattr(h.os.path, "islink", fake_islink)
monkeypatch.setattr(h.os.path, "exists", fake_exists)
monkeypatch.setattr(h.os, "walk", fake_walk)
# Avoid real system access
monkeypatch.setattr(h, "list_enabled_services", lambda: ["openvpn.service"])
monkeypatch.setattr(
h,
"get_unit_info",
lambda unit: UnitInfo(
name=unit,
fragment_path="/lib/systemd/system/openvpn.service",
dropin_paths=[],
env_files=[],
exec_paths=["/usr/sbin/openvpn"],
active_state="inactive",
sub_state="dead",
unit_file_state="enabled",
condition_result=None,
),
)
# 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
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"}},
)
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
)
monkeypatch.setattr(h, "list_manual_packages", lambda: ["openvpn", "curl"])
monkeypatch.setattr(h, "collect_non_system_users", lambda: [])
monkeypatch.setattr(h, "stat_triplet", lambda p: ("root", "root", "0644"))
# Avoid needing source files on disk by implementing our own bundle copier
def fake_copy(bundle_dir: str, role_name: str, abs_path: str, src_rel: str):
dst = Path(bundle_dir) / "artifacts" / role_name / src_rel
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(files.get(abs_path, b""))
monkeypatch.setattr(h, "_copy_into_bundle", fake_copy)
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"])
# Service role captured modified conffile
svc = st["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"]
assert any(
mf["path"] == "/etc/default/keyboard" for mf in etc_custom["managed_files"]
)

115
tests/test_manifest.py Normal file
View file

@ -0,0 +1,115 @@
import json
from pathlib import Path
from enroll.manifest import manifest
def test_manifest_writes_roles_and_playbook_with_clean_when(tmp_path: Path):
bundle = tmp_path / "bundle"
out = tmp_path / "ansible"
(bundle / "artifacts" / "foo" / "etc").mkdir(parents=True, exist_ok=True)
(bundle / "artifacts" / "foo" / "etc" / "foo.conf").write_text(
"x", encoding="utf-8"
)
state = {
"host": {"hostname": "test", "os": "debian"},
"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": [],
},
"etc_custom": {
"role_name": "etc_custom",
"managed_files": [
{
"path": "/etc/default/keyboard",
"src_rel": "etc/default/keyboard",
"owner": "root",
"group": "root",
"mode": "0644",
"reason": "custom_unowned",
}
],
"excluded": [],
"notes": [],
},
"services": [
{
"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": [],
}
],
"package_roles": [
{
"package": "curl",
"role_name": "curl",
"managed_files": [],
"excluded": [],
"notes": [],
}
],
}
bundle.mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
# Create artifact for etc_custom file so copy works
(bundle / "artifacts" / "etc_custom" / "etc" / "default").mkdir(
parents=True, exist_ok=True
)
(bundle / "artifacts" / "etc_custom" / "etc" / "default" / "keyboard").write_text(
"kbd", encoding="utf-8"
)
manifest(str(bundle), str(out))
# Service role: conditional start must be a clean Ansible expression
tasks = (out / "roles" / "foo" / "tasks" / "main.yml").read_text(encoding="utf-8")
assert "when: foo_start | bool" in tasks
# Ensure we didn't emit deprecated/broken '{{ }}' delimiters in when:
for line in tasks.splitlines():
if line.lstrip().startswith("when:"):
assert "{{" not in line and "}}" not in line
defaults = (out / "roles" / "foo" / "defaults" / "main.yml").read_text(
encoding="utf-8"
)
assert "foo_start: false" in defaults
# Playbook should include users, etc_custom, packages, and services
pb = (out / "playbook.yml").read_text(encoding="utf-8")
assert "- users" in pb
assert "- etc_custom" in pb
assert "- curl" in pb
assert "- foo" in pb

8
tests/test_secrets.py Normal file
View file

@ -0,0 +1,8 @@
from enroll.secrets import SecretPolicy
def test_secret_policy_denies_common_backup_files():
pol = SecretPolicy()
assert pol.deny_reason("/etc/shadow-") == "denied_path"
assert pol.deny_reason("/etc/passwd-") == "denied_path"
assert pol.deny_reason("/etc/group-") == "denied_path"