From 2c99cc84aafdb39bdb60979cbf3a8d2660f57e08 Mon Sep 17 00:00:00 2001 From: j4n Date: Wed, 18 Feb 2026 11:04:04 +0100 Subject: [PATCH] cmdeploy: prepare chatmaild/cmdeploy changes for Docker support - chatmaild: - basedeploy.py: Add has_systemd() guard. During Docker image builds there's no running systemd, so deployers that query SystemdEnabled facts would crash; this change might also be helpful for non-systemd platforms. - cmdeploy: - cmdeploy.py: - when deploying to @docker, auto-set CHATMAIL_NOPORTCHECK and CHATMAIL_NOSYSCTL since neither makes sense inside a container - --config default now reads CHATMAIL_INI env var, so Docker entrypoints can point to a mounted ini without CLI flags. - deployers.py: - skip port check / CHATMAIL_NOPORTCHECK - skip echobot systemd cleanup w/ has_systemd - dovecot/deployer.py: - Guard sysctl writes behind CHATMAIL_NOSYSCTL - invert dovecot install check so it works without systemd - sshexec.py: Add __call__ to LocalExec so cmdeploy status works with @local target. Without it, cmdeploy status tried to call the executor directly and got TypeError. Consolidated from j4n/docker branch commits (selection): - 8953fde feat(cmdeploy): read CHATMAIL_INI env var for default --config path - 81d7782 fix(cmdeploy): add __call__ to LocalExec so status works with @local - 8bba78e docker: disable port check if docker is running. fix #694 - 865b514 docker: replace config flags with env vars, drop docker param (instead of f26cb08) Files: cmdeploy/src/cmdeploy/{basedeploy,cmdeploy,deployers,sshexec,dovecot/deployer}.py Co-authored-by: Keonik1 Co-authored-by: missytake --- cmdeploy/src/cmdeploy/basedeploy.py | 5 ++ cmdeploy/src/cmdeploy/cmdeploy.py | 5 +- cmdeploy/src/cmdeploy/deployers.py | 77 ++++++++++++----------- cmdeploy/src/cmdeploy/dovecot/deployer.py | 37 ++++++----- 4 files changed, 71 insertions(+), 53 deletions(-) diff --git a/cmdeploy/src/cmdeploy/basedeploy.py b/cmdeploy/src/cmdeploy/basedeploy.py index dcb17a3c..45654c27 100644 --- a/cmdeploy/src/cmdeploy/basedeploy.py +++ b/cmdeploy/src/cmdeploy/basedeploy.py @@ -5,6 +5,11 @@ import os from pyinfra.operations import files, server, systemd +def has_systemd(): + """Returns False during Docker image builds or any other non-systemd environment.""" + return os.path.isdir("/run/systemd/system") + + def get_resource(arg, pkg=__package__): return importlib.resources.files(pkg).joinpath(arg) diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index 260ef561..b0dde2e2 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -110,6 +110,9 @@ def run_cmd(args, out): cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y" if ssh_host in ["localhost", "@docker"]: + if ssh_host == "@docker": + env["CHATMAIL_NOPORTCHECK"] = "True" + env["CHATMAIL_NOSYSCTL"] = "True" cmd = f"{pyinf} @local {deploy_path} -y" if version.parse(pyinfra.__version__) < version.parse("3"): @@ -336,7 +339,7 @@ def add_config_option(parser): "--config", dest="inipath", action="store", - default=Path("chatmail.ini"), + default=Path(os.environ.get("CHATMAIL_INI", "chatmail.ini")), type=Path, help="path to the chatmail.ini file", ) diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index d06cc62c..07f421a8 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -2,6 +2,7 @@ Chat Mail pyinfra deploy. """ +import os import shutil import subprocess import sys @@ -26,6 +27,7 @@ from .basedeploy import ( activate_remote_units, configure_remote_units, get_resource, + has_systemd, ) from .dovecot.deployer import DovecotDeployer from .filtermail.deployer import FiltermailDeployer @@ -66,6 +68,8 @@ def _build_chatmaild(dist_dir) -> None: def remove_legacy_artifacts(): + if not has_systemd(): + return # disable legacy doveauth-dictproxy.service if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"): systemd.service( @@ -300,7 +304,7 @@ class LegacyRemoveDeployer(Deployer): present=False, ) # remove echobot if it is still running - if host.get_fact(SystemdEnabled).get("echobot.service"): + if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"): systemd.service( name="Disable echobot.service", service="echobot.service", @@ -567,41 +571,42 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n") exit(1) - port_services = [ - (["master", "smtpd"], 25), - ("unbound", 53), - ] - if config.tls_cert_mode == "acme": - port_services.append(("acmetool", 402)) - port_services += [ - (["imap-login", "dovecot"], 143), - # acmetool previously listened on port 80, - # so don't complain during upgrade that moved it to port 402 - # and gave the port to nginx. - (["acmetool", "nginx"], 80), - ("nginx", 443), - (["master", "smtpd"], 465), - (["master", "smtpd"], 587), - (["imap-login", "dovecot"], 993), - ("iroh-relay", 3340), - ("mtail", 3903), - ("stats", 3904), - ("nginx", 8443), - (["master", "smtpd"], config.postfix_reinject_port), - (["master", "smtpd"], config.postfix_reinject_port_incoming), - ("filtermail", config.filtermail_smtp_port), - ("filtermail", config.filtermail_smtp_port_incoming), - ] - for service, port in port_services: - print(f"Checking if port {port} is available for {service}...") - running_service = host.get_fact(Port, port=port) - services = [service] if isinstance(service, str) else service - if running_service: - if running_service not in services: - Out().red( - f"Deploy failed: port {port} is occupied by: {running_service}" - ) - exit(1) + if not os.environ.get("CHATMAIL_NOPORTCHECK"): + port_services = [ + (["master", "smtpd"], 25), + ("unbound", 53), + ] + if config.tls_cert_mode == "acme": + port_services.append(("acmetool", 402)) + port_services += [ + (["imap-login", "dovecot"], 143), + # acmetool previously listened on port 80, + # so don't complain during upgrade that moved it to port 402 + # and gave the port to nginx. + (["acmetool", "nginx"], 80), + ("nginx", 443), + (["master", "smtpd"], 465), + (["master", "smtpd"], 587), + (["imap-login", "dovecot"], 993), + ("iroh-relay", 3340), + ("mtail", 3903), + ("stats", 3904), + ("nginx", 8443), + (["master", "smtpd"], config.postfix_reinject_port), + (["master", "smtpd"], config.postfix_reinject_port_incoming), + ("filtermail", config.filtermail_smtp_port), + ("filtermail", config.filtermail_smtp_port_incoming), + ] + for service, port in port_services: + print(f"Checking if port {port} is available for {service}...") + running_service = host.get_fact(Port, port=port) + services = [service] if isinstance(service, str) else service + if running_service: + if running_service not in services: + Out().red( + f"Deploy failed: port {port} is occupied by: {running_service}" + ) + exit(1) tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"] diff --git a/cmdeploy/src/cmdeploy/dovecot/deployer.py b/cmdeploy/src/cmdeploy/dovecot/deployer.py index 27e8c59e..90f6ecc7 100644 --- a/cmdeploy/src/cmdeploy/dovecot/deployer.py +++ b/cmdeploy/src/cmdeploy/dovecot/deployer.py @@ -1,3 +1,5 @@ +import os + from chatmaild.config import Config from pyinfra import host from pyinfra.facts.server import Arch, Sysctl @@ -9,6 +11,7 @@ from cmdeploy.basedeploy import ( activate_remote_units, configure_remote_units, get_resource, + has_systemd, ) @@ -22,10 +25,11 @@ class DovecotDeployer(Deployer): def install(self): arch = host.get_fact(Arch) - if not host.get_fact(SystemdEnabled).get("dovecot.service"): - _install_dovecot_package("core", arch) - _install_dovecot_package("imapd", arch) - _install_dovecot_package("lmtpd", arch) + if has_systemd() and "dovecot.service" in host.get_fact(SystemdEnabled): + return # already installed and running + _install_dovecot_package("core", arch) + _install_dovecot_package("imapd", arch) + _install_dovecot_package("lmtpd", arch) def configure(self): configure_remote_units(self.config.mail_domain, self.units) @@ -116,18 +120,19 @@ def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool): # as per https://doc.dovecot.org/2.3/configuration_manual/os/ # it is recommended to set the following inotify limits - for name in ("max_user_instances", "max_user_watches"): - key = f"fs.inotify.{name}" - if host.get_fact(Sysctl)[key] > 65535: - # Skip updating limits if already sufficient - # (enables running in incus containers where sysctl readonly) - continue - server.sysctl( - name=f"Change {key}", - key=key, - value=65535, - persist=True, - ) + if not os.environ.get("CHATMAIL_NOSYSCTL"): + for name in ("max_user_instances", "max_user_watches"): + key = f"fs.inotify.{name}" + if host.get_fact(Sysctl)[key] > 65535: + # Skip updating limits if already sufficient + # (enables running in incus containers where sysctl readonly) + continue + server.sysctl( + name=f"Change {key}", + key=key, + value=65535, + persist=True, + ) timezone_env = files.line( name="Set TZ environment variable",