From 3d128d3c6499301c992e13ef6acf404b1703957b Mon Sep 17 00:00:00 2001 From: j4n Date: Tue, 14 Apr 2026 15:21:59 +0200 Subject: [PATCH] test: add dovecot deployer checks Offline tests (test_dovecot_deployer.py, 5 tests): - skips_epoch_matched_install: core epoch bug regression - uses_archive_version_for_url_and_filename: epoch must not leak into URLs - skips_dpkg_path_when_epoch_matched: end-to-end no-op deploy path - unsupported_arch_falls_back_to_apt: integrated apt fallback with mixed changed results to verify |= accumulation - pick_url_falls_back_on_primary_error: URL failover Online test (test_1_basic.py): - dovecot_main_process_matches_installed_binary: stale-binary regression guard: checks /proc/PID/exe is not deleted and status text matches dovecot --version --- .../src/cmdeploy/tests/online/test_1_basic.py | 38 +++ .../cmdeploy/tests/test_dovecot_deployer.py | 238 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py diff --git a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py index 7ff23b5b..1a3fa43a 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py @@ -71,6 +71,44 @@ class TestSSHExecutor: assert (now - since_date).total_seconds() < 60 * 60 * 51 +def test_dovecot_main_process_matches_installed_binary(sshdomain): + sshexec = get_sshexec(sshdomain) + main_pid = int( + sshexec( + call=remote.rshell.shell, + kwargs=dict( + command="timeout 10 systemctl show -p MainPID --value dovecot.service" + ), + ).strip() + ) + assert main_pid != 0, "dovecot.service MainPID is 0 -- service not running?" + + exe = sshexec( + call=remote.rshell.shell, + kwargs=dict(command=f"timeout 10 readlink /proc/{main_pid}/exe"), + ).strip() + status_text = sshexec( + call=remote.rshell.shell, + kwargs=dict( + command="timeout 10 systemctl show -p StatusText --value dovecot.service" + ), + ).strip() + installed_version = sshexec( + call=remote.rshell.shell, kwargs=dict(command="timeout 10 dovecot --version") + ).strip() + + assert not exe.endswith("(deleted)"), ( + f"running dovecot binary was deleted (stale after upgrade): {exe}" + ) + expected_status_text = f"v{installed_version}" + assert status_text == expected_status_text or status_text.startswith( + f"{expected_status_text} " + ), ( + f"dovecot status version mismatch: " + f"StatusText={status_text!r}, installed={installed_version!r}" + ) + + def test_timezone_env(remote): for line in remote.iter_output("env"): print(line) diff --git a/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py b/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py new file mode 100644 index 00000000..c18b9aa7 --- /dev/null +++ b/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py @@ -0,0 +1,238 @@ +from contextlib import nullcontext +from types import SimpleNamespace + +import pytest + +from cmdeploy.dovecot import deployer as dovecot_deployer +from pyinfra.facts.deb import DebPackages + + +def make_host(*fact_pairs): + """Build a mock host; get_fact(cls) dispatches to the provided facts mapping. + + Args: + *fact_pairs: tuples of (fact_class, fact_value) to register + + Returns: + SimpleNamespace with get_fact that raises a clear error if an + unexpected fact type is requested. + """ + facts = dict(fact_pairs) + + def get_fact(cls): + if cls not in facts: + registered = ", ".join(c.__name__ for c in facts) + raise LookupError( + f"unexpected get_fact({cls.__name__}); " + f"only registered: {registered}" + ) + return facts[cls] + + return SimpleNamespace(get_fact=get_fact) + + +@pytest.fixture +def deployer(): + return dovecot_deployer.DovecotDeployer( + SimpleNamespace(mail_domain="chat.example.org"), + disable_mail=False, + ) + + +@pytest.fixture +def patch_blocked(monkeypatch): + monkeypatch.setattr(dovecot_deployer, "blocked_service_startup", nullcontext) + + +@pytest.fixture +def mock_files_put(monkeypatch): + monkeypatch.setattr( + dovecot_deployer.files, + "put", + lambda **kwargs: SimpleNamespace(changed=False), + ) + + +@pytest.fixture +def track_shell(monkeypatch): + calls = [] + monkeypatch.setattr( + dovecot_deployer.server, + "shell", + lambda **kwargs: calls.append(kwargs) or SimpleNamespace(changed=False), + ) + return calls + + +def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch): + epoch_version = dovecot_deployer.DOVECOT_PACKAGE_VERSION + downloads = [] + monkeypatch.setattr( + dovecot_deployer, + "host", + make_host((DebPackages, {"dovecot-core": [epoch_version]})), + ) + monkeypatch.setattr( + dovecot_deployer, + "_pick_url", + lambda primary, fallback: primary, + ) + monkeypatch.setattr( + dovecot_deployer.files, + "download", + lambda **kwargs: downloads.append(kwargs), + ) + + deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64") + + assert deb is None, f"expected no deb path when version matches, got {deb!r}" + assert changed is False, "should not flag changed when version already installed" + assert downloads == [], "should not download when version already installed" + + +def test_download_dovecot_package_uses_archive_version_for_url_and_filename( + monkeypatch, +): + downloads = [] + monkeypatch.setattr( + dovecot_deployer, + "host", + make_host((DebPackages, {})), + ) + monkeypatch.setattr( + dovecot_deployer, + "_pick_url", + lambda primary, fallback: primary, + ) + monkeypatch.setattr( + dovecot_deployer.files, + "download", + lambda **kwargs: downloads.append(kwargs), + ) + + deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64") + + archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B") + expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb" + + # Verify the returned path uses archive version, not package version (with epoch) + assert changed is True, "should flag changed when package not yet installed" + assert deb == expected_deb, f"deb path mismatch: {deb!r} != {expected_deb!r}" + assert dovecot_deployer.DOVECOT_PACKAGE_VERSION not in deb, ( + f"deb path should use archive version (no epoch), got {deb!r}" + ) + assert len(downloads) == 1, "files.download should be called exactly once" + + +def test_install_skips_dpkg_path_when_epoch_matched_packages_present( + deployer, patch_blocked, mock_files_put, track_shell, monkeypatch +): + monkeypatch.setattr( + dovecot_deployer, + "host", + make_host( + ( + dovecot_deployer.DebPackages, + { + "dovecot-core": [dovecot_deployer.DOVECOT_PACKAGE_VERSION], + "dovecot-imapd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION], + "dovecot-lmtpd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION], + }, + ), + (dovecot_deployer.Arch, "x86_64"), + ), + ) + downloads = [] + monkeypatch.setattr( + dovecot_deployer.files, + "download", + lambda **kwargs: downloads.append(kwargs), + ) + + deployer.install() + + assert downloads == [], "should not download when all packages epoch-matched" + assert track_shell == [], "should not run dpkg when all packages epoch-matched" + assert deployer.need_restart is False, ( + "need_restart should be False when nothing changed" + ) + + +def test_install_unsupported_arch_falls_back_to_apt( + deployer, patch_blocked, mock_files_put, track_shell, monkeypatch +): + # For unsupported architectures, all fact lookups return the arch string. + monkeypatch.setattr( + dovecot_deployer, + "host", + SimpleNamespace(get_fact=lambda cls: "riscv64"), + ) + apt_calls = [] + + # Mirrors apt.packages() return value: OperationMeta with .changed property. + # Only lmtpd triggers a change to verify |= accumulation of changed flags. + def fake_apt(**kwargs): + apt_calls.append(kwargs) + changed = "lmtpd" in kwargs["packages"][0] + return SimpleNamespace(changed=changed) + + monkeypatch.setattr(dovecot_deployer.apt, "packages", fake_apt) + + deployer.install() + + actual_pkgs = [c["packages"] for c in apt_calls] + assert actual_pkgs == [["dovecot-core"], ["dovecot-imapd"], ["dovecot-lmtpd"]], ( + f"expected apt install of core/imapd/lmtpd, got {actual_pkgs}" + ) + assert track_shell == [], "should not run dpkg for unsupported arch" + assert deployer.need_restart is True, ( + "need_restart should be True when apt installed a package" + ) + + +def test_install_runs_dpkg_when_packages_need_download( + deployer, patch_blocked, mock_files_put, track_shell, monkeypatch +): + monkeypatch.setattr( + dovecot_deployer, + "host", + make_host( + (dovecot_deployer.DebPackages, {}), + (dovecot_deployer.Arch, "x86_64"), + ), + ) + monkeypatch.setattr( + dovecot_deployer, + "_pick_url", + lambda primary, fallback: primary, + ) + monkeypatch.setattr( + dovecot_deployer.files, + "download", + lambda **kwargs: SimpleNamespace(changed=True), + ) + + deployer.install() + + assert len(track_shell) == 1, ( + f"expected one server.shell() call for dpkg install, got {len(track_shell)}" + ) + cmds = track_shell[0]["commands"] + assert len(cmds) == 3, f"expected 3 dpkg/apt commands, got: {cmds}" + assert cmds[0].startswith("dpkg --force-confdef --force-confold -i ") + assert "apt-get -y --fix-broken install" in cmds[1] + assert cmds[2].startswith("dpkg --force-confdef --force-confold -i ") + assert deployer.need_restart is True, ( + "need_restart should be True after dpkg install" + ) + + +def test_pick_url_falls_back_on_primary_error(monkeypatch): + def raise_error(req, timeout): + raise OSError("connection timeout") + + monkeypatch.setattr(dovecot_deployer.urllib.request, "urlopen", raise_error) + result = dovecot_deployer._pick_url("http://primary", "http://fallback") + assert result == "http://fallback", ( + f"should fall back when primary fails, got {result!r}" + )