feat(cmdeploy): add remove command

This commit is contained in:
ccclxxiii
2026-06-25 08:13:27 -05:00
parent cb1e4ff5bb
commit 4c87ca6eba
5 changed files with 545 additions and 2 deletions
+90 -2
View File
@@ -104,7 +104,9 @@ def run_cmd(args, out):
args.dns_check_disabled = True args.dns_check_disabled = True
if not args.dns_check_disabled: if not args.dns_check_disabled:
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red): if not dns.check_initial_remote_data(
remote_data, strict_tls=strict_tls, print=out.red
):
return 1 return 1
env = os.environ.copy() env = os.environ.copy()
@@ -127,7 +129,11 @@ def run_cmd(args, out):
out.check_call(cmd, env=env) out.check_call(cmd, env=env)
if args.website_only: if args.website_only:
out.green("Website deployment completed.") out.green("Website deployment completed.")
elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]: elif (
not args.dns_check_disabled
and strict_tls
and not remote_data["acme_account_url"]
):
out.red("Deploy completed but letsencrypt not configured") out.red("Deploy completed but letsencrypt not configured")
out.red("Run 'cmdeploy run' again") out.red("Run 'cmdeploy run' again")
elif args.config.ipv4_relay: elif args.config.ipv4_relay:
@@ -141,6 +147,88 @@ def run_cmd(args, out):
return 1 return 1
def remove_cmd_options(parser):
parser.add_argument(
"--dry-run",
dest="dry_run",
action="store_true",
help="show what would be removed without modifying the server",
)
parser.add_argument(
"--yes",
"-y",
dest="yes",
action="store_true",
help="do not ask for confirmation before removing chatmail data",
)
parser.add_argument(
"--keep-packages",
action="store_true",
help="remove chatmail files and users but do not purge installed packages",
)
add_ssh_host_option(parser)
def _confirm_remove(args, out):
if args.yes:
return True
domain = args.config.mail_domain_bare
out.red(
"WARNING: this removes chatmail services, configuration, package state, "
f"and mailbox data for {domain!r}."
)
if args.config.tls_cert_mode == "acme":
out.red(
"DNS records are not removed. If your DNS has a CAA record with "
"a Let's Encrypt accounturi, remove or relax it before deploying "
"this relay again."
)
out.red("Type the chatmail domain to continue.")
try:
answer = input(f"remove {domain}: ")
except EOFError:
answer = ""
if answer != domain:
out.red("Remove cancelled.")
return False
return True
def remove_cmd(args, out):
"""Remove chatmail services, configuration, state, users and packages."""
if not _confirm_remove(args, out):
return 1
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare
env = os.environ.copy()
env["CHATMAIL_INI"] = str(args.inipath)
env["CHATMAIL_KEEP_PACKAGES"] = "True" if args.keep_packages else ""
remove_path = importlib.resources.files(__package__).joinpath("remove.py").resolve()
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
cmd = f"{pyinf} --ssh-user root {ssh_host} {remove_path} -y"
if ssh_host == "localhost":
cmd = f"{pyinf} @local {remove_path} -y"
if version.parse(pyinfra.__version__) < version.parse("3"):
out.red("Please re-run scripts/initenv.sh to update pyinfra to version 3.")
return 1
try:
out.check_call(cmd, env=env)
if args.dry_run:
out.green("Remove dry run completed.")
else:
out.green("Remove completed.")
return 0
except subprocess.CalledProcessError:
out.red("Remove failed")
return 1
def dns_cmd_options(parser): def dns_cmd_options(parser):
parser.add_argument( parser.add_argument(
"--zonefile", "--zonefile",
+22
View File
@@ -0,0 +1,22 @@
import importlib.resources
import os
import pyinfra
# pyinfra runs this module as a python file and not as a module so
# import paths must be absolute
from cmdeploy.removers import remove_chatmail
def main():
config_path = os.getenv(
"CHATMAIL_INI",
importlib.resources.files("cmdeploy").joinpath("../../../chatmail.ini"),
)
keep_packages = bool(os.environ.get("CHATMAIL_KEEP_PACKAGES"))
remove_chatmail(config_path, keep_packages=keep_packages)
if pyinfra.is_cli:
main()
+290
View File
@@ -0,0 +1,290 @@
import shlex
from pathlib import Path
from chatmaild.config import read_config
from pyinfra.operations import apt, files, server
CHATMAIL_UNITS = [
"doveauth.service",
"lastlogin.service",
"chatmail-metadata.service",
"chatmail-expire.service",
"chatmail-expire.timer",
"chatmail-fsreport.service",
"chatmail-fsreport.timer",
"filtermail.service",
"filtermail-incoming.service",
"filtermail-transport.service",
"turnserver.service",
"iroh-relay.service",
"mtail.service",
"acmetool-redirector.service",
"acmetool-reconcile.service",
"acmetool-reconcile.timer",
"tls-cert-reload.path",
"tls-cert-reload.service",
]
LEGACY_UNITS = [
"doveauth-dictproxy.service",
"echobot.service",
"mta-sts-daemon.service",
]
PACKAGE_UNITS = [
"dovecot.service",
"postfix.service",
"nginx.service",
"opendkim.service",
"unbound.service",
"fcgiwrap.service",
]
PACKAGE_NAMES = [
"acmetool",
"dovecot-core",
"dovecot-imapd",
"dovecot-lmtpd",
"postfix",
"opendkim",
"opendkim-tools",
"nginx",
"nginx-common",
"libnginx-mod-stream",
"fcgiwrap",
"unbound",
"unbound-anchor",
"dnsutils",
"python3-virtualenv",
"gcc",
"python3-dev",
"curl",
"rsync",
]
RELAY_FILES = [
"/etc/apt/apt.conf.d/00InstallRecommends",
"/etc/apt/keyrings/obs-home-deltachat.gpg",
"/etc/apt/preferences.d/pin-dovecot",
"/etc/chatmail-nocreate",
"/etc/chatmail-version",
"/etc/cron.d/acmetool",
"/etc/cron.d/chatmail-metrics",
"/etc/cron.d/expunge",
"/etc/dovecot/auth.conf",
"/etc/dovecot/dovecot.conf",
"/etc/dovecot/push_notification.lua",
"/etc/iroh-relay.toml",
"/etc/mailname",
"/etc/mta-sts-daemon.yml",
"/etc/mtail/delivered_mail.mtail",
"/etc/nginx/nginx.conf",
"/etc/opendkim.conf",
"/etc/postfix/lmtp_header_cleanup",
"/etc/postfix/login_map",
"/etc/postfix/main.cf",
"/etc/postfix/master.cf",
"/etc/postfix/smtp_tls_policy_map",
"/etc/postfix/smtp_tls_policy_map.db",
"/etc/postfix/submission_header_cleanup",
"/etc/systemd/journald.conf",
"/etc/systemd/system/mta-sts-daemon.service",
"/etc/systemd/system/dovecot.service.d/10_restart.conf",
"/etc/systemd/system/opendkim.service.d/10-prevent-memory-leak.conf",
"/etc/systemd/system/postfix@.service.d/10_restart.conf",
"/etc/unbound/unbound.conf.d/chatmail.conf",
"/usr/lib/cgi-bin/newemail.py",
"/usr/local/bin/chatmail-turn",
"/usr/local/bin/filtermail",
"/usr/local/bin/iroh-relay",
"/usr/local/bin/mtail",
"/usr/sbin/policy-rc.d",
"/var/www/html/metrics",
]
PACKAGE_CONFIG_DIRS = [
"/etc/dkimkeys",
"/etc/dovecot",
"/etc/nginx",
"/etc/opendkim",
"/etc/postfix",
"/etc/unbound",
]
RELAY_DIRS = [
"/etc/mtail",
"/root/from-cmdeploy",
"/usr/local/lib/chatmaild",
"/usr/local/lib/postfix-mta-sts-resolver",
"/var/lib/dovecot",
"/var/lib/nginx",
"/var/lib/postfix",
"/var/lib/unbound",
"/var/log/nginx",
"/var/spool/postfix",
"/var/www/html",
]
EMPTY_SYSTEMD_DIRS = [
"/etc/systemd/system/dovecot.service.d",
"/etc/systemd/system/opendkim.service.d",
"/etc/systemd/system/postfix@.service.d",
]
RELAY_USERS = ["vmail", "iroh", "opendkim"]
RELAY_GROUPS = ["vmail", "iroh", "opendkim"]
def _quote(value) -> str:
return shlex.quote(str(value))
def _systemd_command(args: str) -> str:
return (
"if command -v systemctl >/dev/null && [ -d /run/systemd/system ]; "
f"then systemctl {args} || true; fi"
)
def _remove_file(path: str):
files.file(name=f"Remove {path}", path=path, present=False)
def _remove_dir(path: str):
files.directory(name=f"Remove {path}", path=path, present=False)
def _remove_units():
units = CHATMAIL_UNITS + LEGACY_UNITS + PACKAGE_UNITS
quoted_units = " ".join(_quote(unit) for unit in units)
server.shell(
name="Stop and disable chatmail services",
commands=[_systemd_command(f"disable --now {quoted_units}")],
)
for unit in CHATMAIL_UNITS + LEGACY_UNITS:
_remove_file(f"/etc/systemd/system/{unit}")
for path in RELAY_FILES:
_remove_file(path)
dirs = " ".join(_quote(path) for path in EMPTY_SYSTEMD_DIRS)
server.shell(
name="Remove empty chatmail systemd drop-in directories",
commands=[f"rmdir --ignore-fail-on-non-empty {dirs} 2>/dev/null || true"],
)
server.shell(
name="Reload systemd after removing chatmail units",
commands=[
_systemd_command("daemon-reload"),
_systemd_command("reset-failed"),
],
)
def _remove_packages(keep_packages: bool):
if keep_packages:
return
apt.packages(
name="Purge chatmail relay packages",
packages=PACKAGE_NAMES,
present=False,
purge=True,
)
server.shell(
name="Remove downloaded dovecot packages",
commands=[
"rm -f -- /root/dovecot-core_*.deb "
"/root/dovecot-imapd_*.deb /root/dovecot-lmtpd_*.deb"
],
)
def _remove_dynamic_state(config, keep_packages: bool):
if not keep_packages:
for path in PACKAGE_CONFIG_DIRS:
_remove_dir(path)
for path in RELAY_DIRS:
_remove_dir(path)
_remove_dir(str(config.mailboxes_dir))
passdb = _quote(config.passdb_path)
server.shell(
name="Remove legacy chatmail passdb files",
commands=[f"rm -f -- {passdb} {passdb}.old {passdb}-*"],
)
home_vmail = Path("/home/vmail")
if home_vmail == config.mailboxes_dir or home_vmail in config.mailboxes_dir.parents:
_remove_dir(str(home_vmail))
if config.tls_cert_mode == "self":
_remove_file("/etc/ssl/certs/mailserver.pem")
_remove_file("/etc/ssl/private/mailserver.key")
elif config.tls_cert_mode == "acme":
_remove_dir("/etc/acme")
_remove_dir("/var/lib/acme")
def _restore_basic_resolver():
server.shell(
name="Restore basic resolver after removing unbound",
commands=[
_systemd_command("unmask systemd-resolved.service"),
_systemd_command("enable --now systemd-resolved.service"),
"if [ -e /run/systemd/resolve/stub-resolv.conf ]; then "
"ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf; "
"else printf 'nameserver 9.9.9.9\\n' >/etc/resolv.conf; fi",
],
)
def _remove_environment_changes():
files.line(
name="Remove chatmail TZ environment override",
path="/etc/environment",
line="TZ=:/etc/localtime",
present=False,
)
files.line(
name="Remove chatmail inotify instance sysctl override",
path="/etc/sysctl.conf",
line="fs.inotify.max_user_instances = 65535",
present=False,
)
files.line(
name="Remove chatmail inotify watches sysctl override",
path="/etc/sysctl.conf",
line="fs.inotify.max_user_watches = 65535",
present=False,
)
files.line(
name="Remove legacy chatmail dovecot package repository",
path="/etc/apt/sources.list",
line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./",
escape_regex_characters=True,
present=False,
)
def _remove_users():
commands = []
for user in RELAY_USERS:
quoted = _quote(user)
commands.append(f"userdel -r {quoted} 2>/dev/null || userdel {quoted} || true")
for group in RELAY_GROUPS:
commands.append(f"groupdel {_quote(group)} 2>/dev/null || true")
server.shell(name="Remove chatmail users and groups", commands=commands)
def remove_chatmail(config_path: Path, keep_packages: bool = False) -> None:
"""Remove the deployed chatmail relay from the target host."""
config = read_config(config_path)
_remove_units()
_remove_packages(keep_packages=keep_packages)
_remove_dynamic_state(config, keep_packages=keep_packages)
_remove_environment_changes()
_restore_basic_resolver()
_remove_users()
@@ -47,6 +47,50 @@ class TestCmdline:
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert out == "[WARNING] 1.3.3.7 is not a domain, skipping DNS checks.\n" assert out == "[WARNING] 1.3.3.7 is not a domain, skipping DNS checks.\n"
def test_remove_cancelled_on_wrong_confirmation(self, tmp_path, monkeypatch):
monkeypatch.delenv("CHATMAIL_INI", raising=False)
inipath = tmp_path / "chatmail.ini"
assert main(["init", "--config", str(inipath), "chat.example.org"]) == 0
def check_call(self, arg, env=None, quiet=False):
raise AssertionError("remove command should not run after cancellation")
monkeypatch.setattr("cmdeploy.cmdeploy.Out.check_call", check_call)
monkeypatch.setattr("builtins.input", lambda prompt: "wrong.example.org")
assert main(["remove", "--config", str(inipath)]) == 1
def test_remove_dry_run_local(self, tmp_path, monkeypatch):
monkeypatch.delenv("CHATMAIL_INI", raising=False)
inipath = tmp_path / "chatmail.ini"
assert main(["init", "--config", str(inipath), "chat.example.org"]) == 0
calls = []
def check_call(self, arg, env=None, quiet=False):
calls.append((arg, env))
monkeypatch.setattr("cmdeploy.cmdeploy.Out.check_call", check_call)
assert (
main(
[
"remove",
"--config",
str(inipath),
"--ssh-host",
"localhost",
"--dry-run",
"--yes",
"--keep-packages",
]
)
== 0
)
cmd, env = calls[0]
assert cmd.startswith("pyinfra --dry @local ")
assert "remove.py -y" in cmd
assert env["CHATMAIL_INI"] == str(inipath)
assert env["CHATMAIL_KEEP_PACKAGES"] == "True"
def test_www_folder(example_config, tmp_path): def test_www_folder(example_config, tmp_path):
reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve() reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve()
@@ -0,0 +1,99 @@
from unittest.mock import call
from cmdeploy import removers
def test_remove_chatmail_purges_packages_and_state(make_config, monkeypatch):
config = make_config("chat.example.org")
apt_calls = []
file_calls = []
dir_calls = []
shell_calls = []
line_calls = []
monkeypatch.setattr(removers.apt, "packages", lambda **kw: apt_calls.append(kw))
monkeypatch.setattr(removers.files, "file", lambda **kw: file_calls.append(kw))
monkeypatch.setattr(removers.files, "directory", lambda **kw: dir_calls.append(kw))
monkeypatch.setattr(removers.server, "shell", lambda **kw: shell_calls.append(kw))
monkeypatch.setattr(removers.files, "line", lambda **kw: line_calls.append(kw))
removers.remove_chatmail(config._inipath)
assert apt_calls == [
{
"name": "Purge chatmail relay packages",
"packages": removers.PACKAGE_NAMES,
"present": False,
"purge": True,
}
]
assert call(path="/usr/local/lib/chatmaild", present=False) in [
call(path=entry["path"], present=entry["present"]) for entry in dir_calls
]
assert call(path=str(config.mailboxes_dir), present=False) in [
call(path=entry["path"], present=entry["present"]) for entry in dir_calls
]
assert any(entry["path"] == "/var/lib/acme" for entry in dir_calls)
assert any(
entry["path"] == "/etc/systemd/system/doveauth.service" for entry in file_calls
)
assert any(entry["path"] == "/etc/postfix/main.cf" for entry in file_calls)
assert any(entry["path"] == "/etc/opendkim.conf" for entry in file_calls)
assert any(entry["path"] == "/etc/postfix" for entry in dir_calls)
assert any(entry["path"] == "/var/log/nginx" for entry in dir_calls)
assert any(
"userdel -r vmail" in command
for entry in shell_calls
for command in entry["commands"]
)
assert any(entry["path"] == "/etc/environment" for entry in line_calls)
def test_remove_chatmail_keep_packages_and_external_tls(make_config, monkeypatch):
config = make_config(
"chat.example.org",
{"tls_external_cert_and_key": "/certs/fullchain.pem /certs/privkey.pem"},
)
apt_calls = []
file_calls = []
dir_calls = []
monkeypatch.setattr(removers.apt, "packages", lambda **kw: apt_calls.append(kw))
monkeypatch.setattr(removers.files, "file", lambda **kw: file_calls.append(kw))
monkeypatch.setattr(removers.files, "directory", lambda **kw: dir_calls.append(kw))
monkeypatch.setattr(removers.server, "shell", lambda **kw: None)
monkeypatch.setattr(removers.files, "line", lambda **kw: None)
removers.remove_chatmail(config._inipath, keep_packages=True)
assert apt_calls == []
removed_files = {entry["path"] for entry in file_calls}
removed_dirs = {entry["path"] for entry in dir_calls}
assert "/certs/fullchain.pem" not in removed_files
assert "/certs/privkey.pem" not in removed_files
assert "/var/lib/acme" not in removed_dirs
assert "/etc/nginx" not in removed_dirs
assert "/etc/unbound" not in removed_dirs
assert "/etc/postfix" not in removed_dirs
assert "/etc/dovecot" not in removed_dirs
assert "/etc/nginx/nginx.conf" in removed_files
assert "/etc/unbound/unbound.conf.d/chatmail.conf" in removed_files
def test_remove_chatmail_removes_self_signed_tls(make_config, monkeypatch):
config = make_config("_test.example.org")
file_calls = []
monkeypatch.setattr(removers.apt, "packages", lambda **kw: None)
monkeypatch.setattr(removers.files, "file", lambda **kw: file_calls.append(kw))
monkeypatch.setattr(removers.files, "directory", lambda **kw: None)
monkeypatch.setattr(removers.server, "shell", lambda **kw: None)
monkeypatch.setattr(removers.files, "line", lambda **kw: None)
removers.remove_chatmail(config._inipath)
removed = {entry["path"] for entry in file_calls}
assert "/etc/ssl/certs/mailserver.pem" in removed
assert "/etc/ssl/private/mailserver.key" in removed