Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog for chatmail deployment

## Unreleased

### Bug Fixes

- Dovecot: restart after package replacement even when `policy-rc.d` blocks package-triggered restarts, avoid reinstalling already-correct packages, and add regressions for stale-binary and epoch-version handling so the master process uses the installed binary again

## 1.9.0 2025-12-18

### Documentation
Expand Down
26 changes: 15 additions & 11 deletions cmdeploy/src/cmdeploy/dovecot/deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
get_resource,
)

DOVECOT_VERSION = "2.3.21+dfsg1-3"
DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3"
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}"

DOVECOT_SHA256 = {
("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
Expand All @@ -41,7 +42,8 @@ def install(self):
with blocked_service_startup():
debs = []
for pkg in ("core", "imapd", "lmtpd"):
deb = _download_dovecot_package(pkg, arch)
deb, changed = _download_dovecot_package(pkg, arch)
self.need_restart |= changed
if deb:
debs.append(deb)
if debs:
Expand All @@ -54,6 +56,7 @@ def install(self):
f"dpkg --force-confdef --force-confold -i {deb_list}",
],
)
self.need_restart = True
files.put(
name="Pin dovecot packages to block Debian dist-upgrades",
src=io.StringIO(
Expand All @@ -66,7 +69,8 @@ def install(self):

def configure(self):
configure_remote_units(self.config.mail_domain, self.units)
self.need_restart, self.daemon_reload = _configure_dovecot(self.config)
nr, self.daemon_reload = _configure_dovecot(self.config)
self.need_restart |= nr

def activate(self):
activate_remote_units(self.units)
Expand Down Expand Up @@ -95,22 +99,22 @@ def _pick_url(primary, fallback):
return fallback


def _download_dovecot_package(package: str, arch: str):
"""Download a dovecot .deb if needed, return its path (or None)."""
def _download_dovecot_package(package: str, arch: 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))
if sha256 is None:
apt.packages(packages=[pkg_name])
return None
op = apt.packages(packages=[pkg_name])
return None, bool(getattr(op, "changed", False))

installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
if DOVECOT_VERSION in installed_versions:
return None
if DOVECOT_PACKAGE_VERSION in installed_versions:
return None, False

url_version = DOVECOT_VERSION.replace("+", "%2B")
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}"
Expand All @@ -125,7 +129,7 @@ def _download_dovecot_package(package: str, arch: str):
cache_time=60 * 60 * 24 * 365 * 10, # never redownload the package
)

return deb_filename
return deb_filename, True


def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
Expand Down
29 changes: 29 additions & 0 deletions cmdeploy/src/cmdeploy/tests/online/test_1_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,35 @@ def test_opendkim_restarted(self, sshexec):
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

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)")
expected_status_text = f"v{installed_version}"
assert status_text == expected_status_text or status_text.startswith(
f"{expected_status_text} "
)


def test_timezone_env(remote):
for line in remote.iter_output("env"):
print(line)
Expand Down
Loading
Loading