Add installation via docker compose (MVP 1)

- Add markdown tabs blocks
- Fix [Issue 604](https://github.com/chatmail/relay/issues/604)
- Add `--skip-dns-check` argument to `cmdeploy run` command
- Add `--force` argument to `cmdeploy init` command
- Add startup for `fcgiwrap.service`
- Add extended check when installing `unbound.service`
- Add configuration parameters (`is_development_instance`, `use_foreign_cert_manager`, `acme_email`, `change_kernel_settings`, `fs_inotify_max_user_instances_and_watchers`)
This commit is contained in:
Keonik1
2025-08-09 15:55:37 +03:00
parent 577c04d537
commit 3826de8c60
24 changed files with 1538 additions and 42 deletions

View File

@@ -15,7 +15,7 @@ from pyinfra import facts, host
from pyinfra.api import FactBase
from pyinfra.facts.files import File
from pyinfra.facts.server import Sysctl
from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.facts.systemd import SystemdEnabled, SystemdStatus
from pyinfra.operations import apt, files, pip, server, systemd
from .acmetool import deploy_acmetool
@@ -395,20 +395,21 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
config=config,
)
# as per https://doc.dovecot.org/configuration_manual/os/
# 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 config.change_kernel_settings:
for name in ("max_user_instances", "max_user_watches"):
key = f"fs.inotify.{name}"
if host.get_fact(Sysctl)[key] == config.fs_inotify_max_user_instances_and_watchers:
# Skip updating limits if already sufficient
# (enables running in incus containers where sysctl readonly)
continue
server.sysctl(
name=f"Change {key}",
key=key,
value=config.fs_inotify_max_user_instances_and_watchers,
persist=True,
)
timezone_env = files.line(
name="Set TZ environment variable",
@@ -676,8 +677,10 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
from cmdeploy.cmdeploy import Out
process_on_53 = host.get_fact(Port, port=53)
if host.get_fact(SystemdStatus, services="unbound").get("unbound.service"):
process_on_53 = "unbound"
if process_on_53 not in (None, "unbound"):
Out().red(f"Can't install unbound: port 53 is occupied by: {process_on_53}")
Out().red(f"Can't install unbound: port 53 is occupied by: {process_on_53}")
exit(1)
apt.packages(
name="Install unbound",
@@ -700,10 +703,12 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
deploy_iroh_relay(config)
# Deploy acmetool to have TLS certificates.
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
deploy_acmetool(
domains=tls_domains,
)
if not config.use_foreign_cert_manager:
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
deploy_acmetool(
email = config.acme_email,
domains=tls_domains,
)
apt.packages(
# required for setfacl for echobot
@@ -783,6 +788,13 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
enabled=True,
restarted=nginx_need_restart,
)
systemd.service(
name="Start and enable fcgiwrap",
service="fcgiwrap.service",
running=True,
enabled=True,
)
# This file is used by auth proxy.
# https://wiki.debian.org/EtcMailName

View File

@@ -32,17 +32,28 @@ def init_cmd_options(parser):
action="store",
help="fully qualified DNS domain name for your chatmail instance",
)
parser.add_argument(
"--force",
dest="recreate_ini",
action="store_true",
help="force reacreate ini file",
)
def init_cmd(args, out):
"""Initialize chatmail config file."""
mail_domain = args.chatmail_domain
inipath = args.inipath
if args.inipath.exists():
print(f"Path exists, not modifying: {args.inipath}")
return 1
else:
write_initial_config(args.inipath, mail_domain, overrides={})
out.green(f"created config file for {mail_domain} in {args.inipath}")
if not args.recreate_ini:
out.green(f"[WARNING] Path exists, not modifying: {inipath}")
return 0
else:
out.yellow(f"[WARNING] Force argument was provided, deleting config file: {inipath}")
inipath.unlink()
write_initial_config(inipath, mail_domain, overrides={})
out.green(f"created config file for {mail_domain} in {inipath}")
def run_cmd_options(parser):
@@ -63,6 +74,12 @@ def run_cmd_options(parser):
dest="ssh_host",
help="specify an SSH host to deploy to; uses mail_domain from chatmail.ini by default",
)
parser.add_argument(
"--skip-dns-check",
dest="dns_check_disabled",
action="store_true",
help="disable checks nslookup for dns",
)
def run_cmd(args, out):
@@ -70,9 +87,10 @@ def run_cmd(args, out):
sshexec = args.get_sshexec()
require_iroh = args.config.enable_iroh_relay
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, print=out.red):
return 1
if not args.dns_check_disabled:
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, print=out.red):
return 1
env = os.environ.copy()
env["CHATMAIL_INI"] = args.inipath
@@ -89,6 +107,9 @@ def run_cmd(args, out):
try:
retcode = out.check_call(cmd, env=env)
if retcode == 0:
server_deployed_message = f"Chatmail server started: https://{args.config.mail_domain}/"
delimiter_line = "=" * len(server_deployed_message)
out.green(f"{delimiter_line}\n{server_deployed_message}\n{delimiter_line}")
out.green("Deploy completed, call `cmdeploy dns` next.")
elif not remote_data["acme_account_url"]:
out.red("Deploy completed but letsencrypt not configured")
@@ -251,8 +272,17 @@ class Out:
def green(self, msg, file=sys.stderr):
print(colored(msg, "green"), file=file)
def __call__(self, msg, red=False, green=False, file=sys.stdout):
color = "red" if red else ("green" if green else None)
def yellow(self, msg, file=sys.stderr):
print(colored(msg, "yellow"), file=file)
def __call__(self, msg, red=False, green=False, yellow=False, file=sys.stdout):
color = None
if red:
color = "red"
elif green:
color = "green"
elif yellow:
color = "yellow"
print(colored(msg, color), file=file)
def check_call(self, arg, env=None, quiet=False):
@@ -331,8 +361,9 @@ def main(args=None):
return parser.parse_args(["-h"])
def get_sshexec():
print(f"[ssh] login to {args.config.mail_domain}")
return SSHExec(args.config.mail_domain, verbose=args.verbose)
host = args.ssh_host if hasattr(args, "ssh_host") and args.ssh_host else args.config.mail_domain
print(f"[ssh] login to {host}")
return SSHExec(host, verbose=args.verbose)
args.get_sshexec = get_sshexec

View File

@@ -25,7 +25,8 @@ def prepare_template(source):
assert source.exists(), source
render_vars = {}
render_vars["pagename"] = "home" if source.stem == "index" else source.stem
render_vars["markdown_html"] = markdown.markdown(source.read_text())
# tabs usage for multiple languages https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/tab/
render_vars["markdown_html"] = markdown.markdown(source.read_text(), extensions=['pymdownx.blocks.tab'])
page_layout = source.with_name("page-layout.html").read_text()
return render_vars, page_layout