diff --git a/cmdeploy/src/cmdeploy/dovecot/deployer.py b/cmdeploy/src/cmdeploy/dovecot/deployer.py index debb7904..a93e1a33 100644 --- a/cmdeploy/src/cmdeploy/dovecot/deployer.py +++ b/cmdeploy/src/cmdeploy/dovecot/deployer.py @@ -1,4 +1,3 @@ -import io import urllib.request from chatmaild.config import Config @@ -19,12 +18,18 @@ DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3" DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}" DOVECOT_SHA256 = { - ("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d", - ("core", "arm64"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9", - ("imapd", "amd64"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86", - ("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f", - ("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab", - ("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f", + ("amd64", "bookworm", "core"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d", + ("arm64", "bookworm", "core"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9", + ("amd64", "bookworm", "imapd"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86", + ("arm64", "bookworm", "imapd"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f", + ("amd64", "bookworm", "lmtpd"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab", + ("arm64", "bookworm", "lmtpd"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f", + ("amd64", "trixie", "core"): "406d3781ed81e0913c472077dcf62cb1106e3855983efa6e44ddf43b4b0c9be1", + ("arm64", "trixie", "core"): "c75b0d9df11a77d07ebd8522920380c167fa47330ddefebe10575d99d0ecdf7f", + ("amd64", "trixie", "imapd"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86", + ("arm64", "trixie", "imapd"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f", + ("amd64", "trixie", "lmtpd"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab", + ("arm64", "trixie", "lmtpd"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f", } @@ -38,34 +43,32 @@ class DovecotDeployer(Deployer): def install(self): arch = host.get_fact(Arch) + codename = (host.get_fact(Command, "grep '^VERSION_CODENAME=' /etc/os-release | cut -d= -f2") or "").strip() + if codename not in {key[1] for key in DOVECOT_SHA256}: + raise ValueError(f"Unsupported Debian codename: {codename!r}") with blocked_service_startup(): debs = [] for pkg in ("core", "imapd", "lmtpd"): - deb, changed = _download_dovecot_package(pkg, arch) + deb, changed = _download_dovecot_package(pkg, arch, codename) self.need_restart |= changed if deb: debs.append(deb) if debs: deb_list = " ".join(debs) - # First dpkg may fail on missing dependencies (stderr suppressed); - # apt-get --fix-broken pulls them in, then dpkg retries cleanly. + # apt-get install with local .deb paths resolves depends + # against the configured repos (e.g. pulls libwrap0), + # The pin file written earlier by ChatmailDeployer prevents apt + # from installing a 'wrong' version server.shell( name="Install dovecot packages", commands=[ - f"dpkg --force-confdef --force-confold -i {deb_list} 2> /dev/null || true", - "DEBIAN_FRONTEND=noninteractive apt-get -y --fix-broken install", - f"dpkg --force-confdef --force-confold -i {deb_list}", + "DEBIAN_FRONTEND=noninteractive apt-get install -y " + '-o Dpkg::Options::="--force-confdef" ' + '-o Dpkg::Options::="--force-confold" ' + f"--allow-downgrades {deb_list}", ], ) self.need_restart = True - self.put_file( - src=io.StringIO( - "Package: dovecot-*\n" - "Pin: version *\n" - "Pin-Priority: -1\n" - ), - dest="/etc/apt/preferences.d/pin-dovecot", - ) def configure(self): configure_remote_units(self, self.config.mail_domain_bare, self.units) @@ -78,7 +81,7 @@ class DovecotDeployer(Deployer): if not self.disable_mail and not self.need_restart: stale = host.get_fact( Command, - 'pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);' + "pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);" ' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"' " && echo STALE || true", ) @@ -102,13 +105,13 @@ def _pick_url(primary, fallback): return fallback -def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool]: +def _download_dovecot_package(package: str, arch: str, codename: str) -> tuple[str | None, bool]: """Download a dovecot .deb if needed, return (path, changed).""" arch = "amd64" if arch == "x86_64" else arch arch = "arm64" if arch == "aarch64" else arch pkg_name = f"dovecot-{package}" - sha256 = DOVECOT_SHA256.get((package, arch)) + sha256 = DOVECOT_SHA256.get((arch, codename, package)) if sha256 is None: op = apt.packages(packages=[pkg_name]) return None, bool(getattr(op, "changed", False)) @@ -119,8 +122,10 @@ def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool url_version = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B") deb_base = f"{pkg_name}_{url_version}_{arch}.deb" - primary_url = f"https://download.delta.chat/dovecot/{deb_base}" - fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}" + primary_url = f"https://download.delta.chat/dovecot/{codename}/{url_version}/{deb_base}" + upstream_version = DOVECOT_ARCHIVE_VERSION.rsplit("-", 1)[0].replace("+", "%2B") + fallback_deb = f"{pkg_name}_{url_version}_{arch}_{codename}.deb" + fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{upstream_version}/{fallback_deb}" url = _pick_url(primary_url, fallback_url) deb_filename = f"/root/{deb_base}" @@ -134,6 +139,7 @@ def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool return deb_filename, True + def _configure_dovecot(deployer, config: Config, debug: bool = False): """Configures Dovecot IMAP server.""" deployer.put_template( @@ -144,9 +150,7 @@ def _configure_dovecot(deployer, config: Config, debug: bool = False): disable_ipv6=config.disable_ipv6, ) deployer.put_file("dovecot/auth.conf", "/etc/dovecot/auth.conf") - deployer.put_file( - "dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua" - ) + deployer.put_file("dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua") # as per https://doc.dovecot.org/2.3/configuration_manual/os/ # it is recommended to set the following inotify limits diff --git a/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py b/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py index 8c615671..b7efb5a3 100644 --- a/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py +++ b/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py @@ -3,6 +3,7 @@ from types import SimpleNamespace import pytest from pyinfra.facts.deb import DebPackages +from pyinfra.facts.server import Command from cmdeploy.dovecot import deployer as dovecot_deployer @@ -19,7 +20,7 @@ def make_host(*fact_pairs): """ facts = dict(fact_pairs) - def get_fact(cls): + def get_fact(cls, *args): if cls not in facts: registered = ", ".join(c.__name__ for c in facts) raise LookupError( @@ -82,7 +83,9 @@ def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch): lambda **kwargs: downloads.append(kwargs), ) - deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64") + deb, changed = dovecot_deployer._download_dovecot_package( + "core", "amd64", codename="bookworm" + ) 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" @@ -109,7 +112,9 @@ def test_download_dovecot_package_uses_archive_version_for_url_and_filename( lambda **kwargs: downloads.append(kwargs), ) - deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64") + deb, changed = dovecot_deployer._download_dovecot_package( + "core", "amd64", codename="bookworm" + ) archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B") expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb" @@ -139,6 +144,7 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present( }, ), (dovecot_deployer.Arch, "x86_64"), + (Command, "bookworm"), ), ) downloads = [] @@ -160,11 +166,13 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present( 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"), + make_host( + (dovecot_deployer.Arch, "riscv64"), + (Command, "bookworm"), + ), ) apt_calls = [] @@ -198,6 +206,7 @@ def test_install_runs_dpkg_when_packages_need_download( make_host( (dovecot_deployer.DebPackages, {}), (dovecot_deployer.Arch, "x86_64"), + (Command, "bookworm"), ), ) monkeypatch.setattr( @@ -217,10 +226,12 @@ def test_install_runs_dpkg_when_packages_need_download( 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 len(cmds) == 1, f"expected single apt-get install command, got: {cmds}" + assert "apt-get install -y" in cmds[0] + assert '-o Dpkg::Options::="--force-confdef"' in cmds[0] + assert '-o Dpkg::Options::="--force-confold"' in cmds[0] + assert "--allow-downgrades" in cmds[0] + assert ".deb" in cmds[0] assert deployer.need_restart is True, ( "need_restart should be True after dpkg install" ) @@ -235,3 +246,19 @@ def test_pick_url_falls_back_on_primary_error(monkeypatch): assert result == "http://fallback", ( f"should fall back when primary fails, got {result!r}" ) + + +def test_install_fails_on_unsupported_debian_version( + deployer, patch_blocked, monkeypatch +): + monkeypatch.setattr( + dovecot_deployer, + "host", + make_host( + (dovecot_deployer.Arch, "x86_64"), + (Command, "sid"), + ), + ) + + with pytest.raises(ValueError, match="Unsupported Debian codename"): + deployer.install()