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 <keonik.dev@gmail.com>
Co-authored-by: missytake <missytake@systemli.org>
This commit is contained in:
j4n
2026-02-18 11:04:04 +01:00
parent 73309778c2
commit 2c99cc84aa
4 changed files with 71 additions and 53 deletions

View File

@@ -5,6 +5,11 @@ import os
from pyinfra.operations import files, server, systemd 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__): def get_resource(arg, pkg=__package__):
return importlib.resources.files(pkg).joinpath(arg) return importlib.resources.files(pkg).joinpath(arg)

View File

@@ -110,6 +110,9 @@ def run_cmd(args, out):
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y" cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
if ssh_host in ["localhost", "@docker"]: 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" cmd = f"{pyinf} @local {deploy_path} -y"
if version.parse(pyinfra.__version__) < version.parse("3"): if version.parse(pyinfra.__version__) < version.parse("3"):
@@ -336,7 +339,7 @@ def add_config_option(parser):
"--config", "--config",
dest="inipath", dest="inipath",
action="store", action="store",
default=Path("chatmail.ini"), default=Path(os.environ.get("CHATMAIL_INI", "chatmail.ini")),
type=Path, type=Path,
help="path to the chatmail.ini file", help="path to the chatmail.ini file",
) )

View File

@@ -2,6 +2,7 @@
Chat Mail pyinfra deploy. Chat Mail pyinfra deploy.
""" """
import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -26,6 +27,7 @@ from .basedeploy import (
activate_remote_units, activate_remote_units,
configure_remote_units, configure_remote_units,
get_resource, get_resource,
has_systemd,
) )
from .dovecot.deployer import DovecotDeployer from .dovecot.deployer import DovecotDeployer
from .filtermail.deployer import FiltermailDeployer from .filtermail.deployer import FiltermailDeployer
@@ -66,6 +68,8 @@ def _build_chatmaild(dist_dir) -> None:
def remove_legacy_artifacts(): def remove_legacy_artifacts():
if not has_systemd():
return
# disable legacy doveauth-dictproxy.service # disable legacy doveauth-dictproxy.service
if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"): if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"):
systemd.service( systemd.service(
@@ -300,7 +304,7 @@ class LegacyRemoveDeployer(Deployer):
present=False, present=False,
) )
# remove echobot if it is still running # 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( systemd.service(
name="Disable echobot.service", name="Disable echobot.service",
service="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") Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
exit(1) exit(1)
port_services = [ if not os.environ.get("CHATMAIL_NOPORTCHECK"):
(["master", "smtpd"], 25), port_services = [
("unbound", 53), (["master", "smtpd"], 25),
] ("unbound", 53),
if config.tls_cert_mode == "acme": ]
port_services.append(("acmetool", 402)) if config.tls_cert_mode == "acme":
port_services += [ port_services.append(("acmetool", 402))
(["imap-login", "dovecot"], 143), port_services += [
# acmetool previously listened on port 80, (["imap-login", "dovecot"], 143),
# so don't complain during upgrade that moved it to port 402 # acmetool previously listened on port 80,
# and gave the port to nginx. # so don't complain during upgrade that moved it to port 402
(["acmetool", "nginx"], 80), # and gave the port to nginx.
("nginx", 443), (["acmetool", "nginx"], 80),
(["master", "smtpd"], 465), ("nginx", 443),
(["master", "smtpd"], 587), (["master", "smtpd"], 465),
(["imap-login", "dovecot"], 993), (["master", "smtpd"], 587),
("iroh-relay", 3340), (["imap-login", "dovecot"], 993),
("mtail", 3903), ("iroh-relay", 3340),
("stats", 3904), ("mtail", 3903),
("nginx", 8443), ("stats", 3904),
(["master", "smtpd"], config.postfix_reinject_port), ("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port_incoming), (["master", "smtpd"], config.postfix_reinject_port),
("filtermail", config.filtermail_smtp_port), (["master", "smtpd"], config.postfix_reinject_port_incoming),
("filtermail", config.filtermail_smtp_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}...") for service, port in port_services:
running_service = host.get_fact(Port, port=port) print(f"Checking if port {port} is available for {service}...")
services = [service] if isinstance(service, str) else service running_service = host.get_fact(Port, port=port)
if running_service: services = [service] if isinstance(service, str) else service
if running_service not in services: if running_service:
Out().red( if running_service not in services:
f"Deploy failed: port {port} is occupied by: {running_service}" Out().red(
) f"Deploy failed: port {port} is occupied by: {running_service}"
exit(1) )
exit(1)
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"] tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]

View File

@@ -1,3 +1,5 @@
import os
from chatmaild.config import Config from chatmaild.config import Config
from pyinfra import host from pyinfra import host
from pyinfra.facts.server import Arch, Sysctl from pyinfra.facts.server import Arch, Sysctl
@@ -9,6 +11,7 @@ from cmdeploy.basedeploy import (
activate_remote_units, activate_remote_units,
configure_remote_units, configure_remote_units,
get_resource, get_resource,
has_systemd,
) )
@@ -22,10 +25,11 @@ class DovecotDeployer(Deployer):
def install(self): def install(self):
arch = host.get_fact(Arch) arch = host.get_fact(Arch)
if not host.get_fact(SystemdEnabled).get("dovecot.service"): if has_systemd() and "dovecot.service" in host.get_fact(SystemdEnabled):
_install_dovecot_package("core", arch) return # already installed and running
_install_dovecot_package("imapd", arch) _install_dovecot_package("core", arch)
_install_dovecot_package("lmtpd", arch) _install_dovecot_package("imapd", arch)
_install_dovecot_package("lmtpd", arch)
def configure(self): def configure(self):
configure_remote_units(self.config.mail_domain, self.units) 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/ # as per https://doc.dovecot.org/2.3/configuration_manual/os/
# it is recommended to set the following inotify limits # it is recommended to set the following inotify limits
for name in ("max_user_instances", "max_user_watches"): if not os.environ.get("CHATMAIL_NOSYSCTL"):
key = f"fs.inotify.{name}" for name in ("max_user_instances", "max_user_watches"):
if host.get_fact(Sysctl)[key] > 65535: key = f"fs.inotify.{name}"
# Skip updating limits if already sufficient if host.get_fact(Sysctl)[key] > 65535:
# (enables running in incus containers where sysctl readonly) # Skip updating limits if already sufficient
continue # (enables running in incus containers where sysctl readonly)
server.sysctl( continue
name=f"Change {key}", server.sysctl(
key=key, name=f"Change {key}",
value=65535, key=key,
persist=True, value=65535,
) persist=True,
)
timezone_env = files.line( timezone_env = files.line(
name="Set TZ environment variable", name="Set TZ environment variable",