From 861fdf7a5081011e4b29fa11d3959a5b3ad86761 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Thu, 5 Mar 2026 19:31:41 +0100 Subject: [PATCH 01/31] feat: add LXC container support for local chatmail development Add cmdeploy "lxc-test" command to run cmdeploy against local containers, with supplementary lxc-start, lxc-stop and lxc-status subcommands. See doc/source/lxc.rst for full documentation including prerequisites, DNS setup, TLS handling, DNS-free testing, and known limitations. Apart from adding lxc-specific docs, tests, and implementation files in the cmdeploy/lxc directory, this PR adds the --ssh-config option to cmdeploy run/dns/status/test commands and pyinfra invocations, and also to sshexec (Execnet) handling. This allows for the host to need no DNS entries for a relay, and route all resolution through ssh-config. This is used by the "lxc-test" command, which performs a completely local setup -- again, see docs for more details. While working on DNS/SSH things i also unified all zone-file handling to use actual BIND format as it is easy enough to parse back. --- .gitignore | 1 + cmdeploy/src/cmdeploy/chatmail.zone.j2 | 32 - cmdeploy/src/cmdeploy/cmdeploy.py | 90 ++- cmdeploy/src/cmdeploy/deployers.py | 31 +- cmdeploy/src/cmdeploy/dns.py | 56 +- cmdeploy/src/cmdeploy/lxc/cli.py | 469 +++++++++++++ cmdeploy/src/cmdeploy/lxc/incus.py | 638 ++++++++++++++++++ cmdeploy/src/cmdeploy/remote/rdns.py | 12 +- cmdeploy/src/cmdeploy/selfsigned/deployer.py | 32 +- cmdeploy/src/cmdeploy/sshexec.py | 52 +- cmdeploy/src/cmdeploy/tests/data/zftest.zone | 33 +- .../src/cmdeploy/tests/online/test_0_qr.py | 19 +- .../src/cmdeploy/tests/online/test_1_basic.py | 11 +- .../cmdeploy/tests/online/test_2_deltachat.py | 5 +- .../cmdeploy/tests/online/test_3_status.py | 3 + cmdeploy/src/cmdeploy/tests/plugin.py | 109 ++- cmdeploy/src/cmdeploy/tests/test_cmdeploy.py | 5 +- cmdeploy/src/cmdeploy/tests/test_dns.py | 18 +- cmdeploy/src/cmdeploy/tests/test_lxc.py | 174 +++++ cmdeploy/src/cmdeploy/util.py | 63 ++ doc/source/conf.py | 2 +- doc/source/index.rst | 1 + doc/source/lxc.rst | 312 +++++++++ 23 files changed, 2028 insertions(+), 140 deletions(-) delete mode 100644 cmdeploy/src/cmdeploy/chatmail.zone.j2 create mode 100644 cmdeploy/src/cmdeploy/lxc/cli.py create mode 100644 cmdeploy/src/cmdeploy/lxc/incus.py create mode 100644 cmdeploy/src/cmdeploy/tests/test_lxc.py create mode 100644 cmdeploy/src/cmdeploy/util.py create mode 100644 doc/source/lxc.rst diff --git a/.gitignore b/.gitignore index c0f40b9b1..a542cf469 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ *.swp *qr-*.png chatmail*.ini +lxconfigs/ # C extensions diff --git a/cmdeploy/src/cmdeploy/chatmail.zone.j2 b/cmdeploy/src/cmdeploy/chatmail.zone.j2 deleted file mode 100644 index 9915ae68d..000000000 --- a/cmdeploy/src/cmdeploy/chatmail.zone.j2 +++ /dev/null @@ -1,32 +0,0 @@ -; -; Required DNS entries for chatmail servers -; -{% if A %} -{{ mail_domain }}. A {{ A }} -{% endif %} -{% if AAAA %} -{{ mail_domain }}. AAAA {{ AAAA }} -{% endif %} -{{ mail_domain }}. MX 10 {{ mail_domain }}. -{% if strict_tls %} -_mta-sts.{{ mail_domain }}. TXT "v=STSv1; id={{ sts_id }}" -mta-sts.{{ mail_domain }}. CNAME {{ mail_domain }}. -{% endif %} -www.{{ mail_domain }}. CNAME {{ mail_domain }}. -{{ dkim_entry }} - -; -; Recommended DNS entries for interoperability and security-hardening -; -{{ mail_domain }}. TXT "v=spf1 a ~all" -_dmarc.{{ mail_domain }}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s" - -{% if acme_account_url %} -{{ mail_domain }}. CAA 0 issue "letsencrypt.org;accounturi={{ acme_account_url }}" -{% endif %} -_adsp._domainkey.{{ mail_domain }}. TXT "dkim=discardable" - -_submission._tcp.{{ mail_domain }}. SRV 0 1 587 {{ mail_domain }}. -_submissions._tcp.{{ mail_domain }}. SRV 0 1 465 {{ mail_domain }}. -_imap._tcp.{{ mail_domain }}. SRV 0 1 143 {{ mail_domain }}. -_imaps._tcp.{{ mail_domain }}. SRV 0 1 993 {{ mail_domain }}. diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index aace16932..4cd081f6d 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -18,7 +18,23 @@ from termcolor import colored from . import dns, remote -from .sshexec import LocalExec, SSHExec +from .lxc.cli import ( # noqa: F401 + lxc_start_cmd, + lxc_start_cmd_options, + lxc_status_cmd, + lxc_status_cmd_options, + lxc_stop_cmd, + lxc_stop_cmd_options, + lxc_test_cmd, + lxc_test_cmd_options, +) +from .sshexec import ( + LocalExec, + SSHExec, + resolve_host_from_ssh_config, + resolve_key_from_ssh_config, +) +from .www import main as webdev_main # # cmdeploy sub commands and options @@ -82,18 +98,21 @@ def run_cmd_options(parser): help="disable checks nslookup for dns", ) add_ssh_host_option(parser) + add_ssh_config_option(parser) def run_cmd(args, out): """Deploy chatmail services on the remote server.""" ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain - sshexec = get_sshexec(ssh_host) + sshexec = get_sshexec(ssh_host, ssh_config=args.ssh_config) require_iroh = args.config.enable_iroh_relay strict_tls = args.config.tls_cert_mode == "acme" 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, 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 env = os.environ.copy() @@ -108,6 +127,18 @@ def run_cmd(args, out): pyinf = "pyinfra --dry" if args.dry_run else "pyinfra" cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y" + ssh_config = args.ssh_config + if ssh_config: + ssh_config = str(Path(ssh_config).resolve()) + + # Use pyinfra's native SSH data keys to configure the connection directly + # rather than relying on paramiko config parsing (see also sshexec.py) + ip = resolve_host_from_ssh_config(ssh_host, ssh_config) + key = resolve_key_from_ssh_config(ssh_host, ssh_config) + data_args = f"--data ssh_hostname={ip} --data ssh_known_hosts_file=/dev/null" + if key: + data_args += f" --data ssh_key={key}" + cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y {data_args}" if ssh_host in ["localhost", "@docker"]: if ssh_host == "@docker": env["CHATMAIL_NOPORTCHECK"] = "True" @@ -122,7 +153,11 @@ def run_cmd(args, out): out.check_call(cmd, env=env) if args.website_only: 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("Run 'cmdeploy run' again") else: @@ -139,15 +174,16 @@ def dns_cmd_options(parser): dest="zonefile", type=pathlib.Path, default=None, - help="write out a zonefile", + help="write DNS records in standard BIND format to the given file", ) add_ssh_host_option(parser) + add_ssh_config_option(parser) def dns_cmd(args, out): """Check DNS entries and optionally generate dns zone file.""" ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain - sshexec = get_sshexec(ssh_host, verbose=args.verbose) + sshexec = get_sshexec(ssh_host, verbose=args.verbose, ssh_config=args.ssh_config) tls_cert_mode = args.config.tls_cert_mode strict_tls = tls_cert_mode == "acme" remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) @@ -178,13 +214,14 @@ def dns_cmd(args, out): def status_cmd_options(parser): add_ssh_host_option(parser) + add_ssh_config_option(parser) def status_cmd(args, out): """Display status for online chatmail instance.""" ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain - sshexec = get_sshexec(ssh_host, verbose=args.verbose) + sshexec = get_sshexec(ssh_host, verbose=args.verbose, ssh_config=args.ssh_config) out.green(f"chatmail domain: {args.config.mail_domain}") if args.config.privacy_mail: @@ -204,14 +241,18 @@ def test_cmd_options(parser): help="also run slow tests", ) add_ssh_host_option(parser) + add_ssh_config_option(parser) def test_cmd(args, out): """Run local and online tests for chatmail deployment.""" env = os.environ.copy() + env["CHATMAIL_INI"] = str(args.inipath.resolve()) if args.ssh_host: env["CHATMAIL_SSH"] = args.ssh_host + if args.ssh_config: + env["CHATMAIL_SSH_CONFIG"] = str(Path(args.ssh_config).resolve()) pytest_path = shutil.which("pytest") pytest_args = [ @@ -276,9 +317,7 @@ def bench_cmd(args, out): def webdev_cmd(args, out): """Run local web development loop for static web pages.""" - from .www import main - - main() + webdev_main() # @@ -321,6 +360,16 @@ def add_ssh_host_option(parser): ) +def add_ssh_config_option(parser): + parser.add_argument( + "--ssh-config", + dest="ssh_config", + type=Path, + default=None, + help="Path to an SSH config file (e.g. lxconfigs/ssh-config).", + ) + + def add_config_option(parser): parser.add_argument( "--config", @@ -330,6 +379,7 @@ def add_config_option(parser): type=Path, help="path to the chatmail.ini file", ) + parser.add_argument( "--verbose", "-v", @@ -340,15 +390,16 @@ def add_config_option(parser): ) -def add_subcommand(subparsers, func): +def add_subcommand(subparsers, func, add_config=True): name = func.__name__ assert name.endswith("_cmd") - name = name[:-4] + name = name[:-4].replace("_", "-") doc = func.__doc__.strip() help = doc.split("\n")[0].strip(".") p = subparsers.add_parser(name, description=doc, help=help) p.set_defaults(func=func) - add_config_option(p) + if add_config: + add_config_option(p) return p @@ -362,13 +413,15 @@ def get_parser(): """Return an ArgumentParser for the 'cmdeploy' CLI""" parser = argparse.ArgumentParser(description=description.strip()) + parser.set_defaults(func=None, inipath=None) subparsers = parser.add_subparsers(title="subcommands") # find all subcommands in the module namespace glob = globals() for name, func in glob.items(): if name.endswith("_cmd"): - subparser = add_subcommand(subparsers, func) + needs_config = not name.startswith("lxc_") + subparser = add_subcommand(subparsers, func, add_config=needs_config) addopts = glob.get(name + "_options") if addopts is not None: addopts(subparser) @@ -376,26 +429,27 @@ def get_parser(): return parser -def get_sshexec(ssh_host: str, verbose=True): +def get_sshexec(ssh_host: str, verbose=True, ssh_config=None): if ssh_host in ["localhost", "@local"]: return LocalExec(verbose, docker=False) elif ssh_host == "@docker": return LocalExec(verbose, docker=True) if verbose: print(f"[ssh] login to {ssh_host}") - return SSHExec(ssh_host, verbose=verbose) + return SSHExec(ssh_host, verbose=verbose, ssh_config=ssh_config) def main(args=None): """Provide main entry point for 'cmdeploy' CLI invocation.""" parser = get_parser() args = parser.parse_args(args=args) - if not hasattr(args, "func"): + if args.func is None: return parser.parse_args(["-h"]) out = Out() kwargs = {} - if args.func.__name__ not in ("init_cmd", "fmt_cmd"): + + if args.inipath is not None and args.func.__name__ not in ("init_cmd", "fmt_cmd"): if not args.inipath.exists(): out.red(f"expecting {args.inipath} to exist, run init first?") raise SystemExit(1) diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index 11536062d..c298d8035 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -18,6 +18,7 @@ from pyinfra.operations import apt, files, pip, server, systemd from cmdeploy.cmdeploy import Out +from cmdeploy.util import get_version_string from .acmetool import AcmetoolDeployer from .basedeploy import ( @@ -271,8 +272,14 @@ def configure(self): logger.warning("Web page build failed, skipping website deployment") return # if it is not a hugo page, upload it as is - files.rsync( - f"{www_path}/", "/var/www/html", flags=["-avz", "--chown=www-data"] + # pyinfra files.rsync (experimental) causes problems with ssh-config configuration + # the stable files.sync should do + files.sync( + src=str(www_path), + dest="/var/www/html", + user="www-data", + group="www-data", + delete=True, ) @@ -532,17 +539,9 @@ def activate(self): class GithashDeployer(Deployer): def activate(self): - try: - git_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode() - except Exception: - git_hash = "unknown\n" - try: - git_diff = subprocess.check_output(["git", "diff"]).decode() - except Exception: - git_diff = "" files.put( name="Upload chatmail relay git commit hash", - src=StringIO(git_hash + git_diff), + src=StringIO(get_version_string()), dest="/etc/chatmail-version", mode="700", ) @@ -586,11 +585,17 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - ) # Check if mtail_address interface is available (if configured) - if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'): + if config.mtail_address and config.mtail_address not in ( + "127.0.0.1", + "::1", + "localhost", + ): ipv4_addrs = host.get_fact(hardware.Ipv4Addrs) all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs] if config.mtail_address not in all_addresses: - 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) if not os.environ.get("CHATMAIL_NOPORTCHECK"): diff --git a/cmdeploy/src/cmdeploy/dns.py b/cmdeploy/src/cmdeploy/dns.py index 05421b9ed..2370ef7df 100644 --- a/cmdeploy/src/cmdeploy/dns.py +++ b/cmdeploy/src/cmdeploy/dns.py @@ -1,11 +1,26 @@ import datetime -import importlib - -from jinja2 import Template from . import remote +def parse_zone_records(text): + """Yield ``(name, ttl, rtype, rdata)`` from standard BIND-format text. + + Skips comment lines (starting with ``;``) and blank lines. + Each record line must have the format ``name TTL IN type rdata``. + """ + for raw_line in text.strip().splitlines(): + line = raw_line.strip() + if not line or line.startswith(";"): + continue + parts = line.split(None, 4) + if len(parts) < 5: + raise ValueError(f"Bad zone record line: {line}") + name = parts[0].rstrip(".") + # parts[2] is the IN class — ignored + yield name, parts[1], parts[3].upper(), parts[4] + + def get_initial_remote_data(sshexec, mail_domain): return sshexec.logged( call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain) @@ -31,13 +46,36 @@ def get_filled_zone_file(remote_data): if not sts_id: remote_data["sts_id"] = datetime.datetime.now().strftime("%Y%m%d%H%M") - template = importlib.resources.files(__package__).joinpath("chatmail.zone.j2") - content = template.read_text() - zonefile = Template(content).render(**remote_data) - lines = [x.strip() for x in zonefile.split("\n") if x.strip()] + d = remote_data["mail_domain"] + lines = ["; Required DNS entries"] + if remote_data.get("A"): + lines.append(f"{d}. 3600 IN A {remote_data['A']}") + if remote_data.get("AAAA"): + lines.append(f"{d}. 3600 IN AAAA {remote_data['AAAA']}") + lines.append(f"{d}. 3600 IN MX 10 {d}.") + if remote_data.get("strict_tls"): + lines.append( + f'_mta-sts.{d}. 3600 IN TXT "v=STSv1; id={remote_data["sts_id"]}"' + ) + lines.append(f"mta-sts.{d}. 3600 IN CNAME {d}.") + lines.append(f"www.{d}. 3600 IN CNAME {d}.") + lines.append(remote_data["dkim_entry"]) + lines.append("") + lines.append("; Recommended DNS entries") + lines.append(f'{d}. 3600 IN TXT "v=spf1 a ~all"') + lines.append(f'_dmarc.{d}. 3600 IN TXT "v=DMARC1;p=reject;adkim=s;aspf=s"') + if remote_data.get("acme_account_url"): + lines.append( + f"{d}. 3600 IN CAA 0 issue" + f' "letsencrypt.org;accounturi={remote_data["acme_account_url"]}"' + ) + lines.append(f'_adsp._domainkey.{d}. 3600 IN TXT "dkim=discardable"') + lines.append(f"_submission._tcp.{d}. 3600 IN SRV 0 1 587 {d}.") + lines.append(f"_submissions._tcp.{d}. 3600 IN SRV 0 1 465 {d}.") + lines.append(f"_imap._tcp.{d}. 3600 IN SRV 0 1 143 {d}.") + lines.append(f"_imaps._tcp.{d}. 3600 IN SRV 0 1 993 {d}.") lines.append("") - zonefile = "\n".join(lines) - return zonefile + return "\n".join(lines) def check_full_zone(sshexec, remote_data, out, zonefile) -> int: diff --git a/cmdeploy/src/cmdeploy/lxc/cli.py b/cmdeploy/src/cmdeploy/lxc/cli.py new file mode 100644 index 000000000..1718a74da --- /dev/null +++ b/cmdeploy/src/cmdeploy/lxc/cli.py @@ -0,0 +1,469 @@ +"""lxc-start/stop/status/test subcommands for testing with local containers.""" + +import os +import subprocess +import time +from contextlib import contextmanager + +from ..util import collapse, get_git_hash, get_version_string, shell +from .incus import Incus, RelayContainer + +RELAY_NAMES = ("test0", "test1") + + +# ------------------------------------------------------------------- +# lxc-start +# ------------------------------------------------------------------- + + +def lxc_start_cmd_options(parser): + _add_name_args( + parser, + help_text="User relay name(s) to create (default: test0).", + ) + parser.add_argument( + "--ipv4-only", + dest="ipv4_only", + action="store_true", + help="Create an IPv4-only container.", + ) + parser.add_argument( + "--run", + action="store_true", + help="Run 'cmdeploy run' on each container after starting it.", + ) + + +def lxc_start_cmd(args, out): + """Create/Ensure and start LXC relay and DNS containers.""" + ix = Incus() + relays = [ix.get_container(n) for n in args.names] or [ + ix.get_container(RELAY_NAMES[0]) + ] + out.green("Ensuring DNS container (ns-localchat) ...") + dns_ct = ix.get_dns_container() + dns_ct.ensure() + dns_ip = dns_ct.ipv4 + print(f" DNS container IP: {dns_ip}") + + for ct in relays: + out.green(f"Ensuring container {ct.name!r} ({ct.domain}) ...") + ct.ensure() + ip = ct.ipv4 + + print(" Configuring container hostname ...") + ct.configure_hosts(ip) + + print(f" Writing {ct.ini.name} ...") + ct.write_ini(disable_ipv6=args.ipv4_only) + print(f" Config: {ct.ini}") + if args.ipv4_only: + ct.disable_ipv6() + ipv6 = None + else: + output = ct.bash( + "ip -6 addr show scope global -deprecated" + " | grep -oP '(?<=inet6 )[^/]+'", + check=False, + ) + ipv6 = output.strip() if output else None + print(f" {_format_addrs(ip, ipv6)}") + + out.green(f" Container {ct.name!r} ready: {ct.domain} -> {ip}") + print() + + # Reset DNS zones only for the containers we just started + started_cnames = {ct.name for ct in relays} + managed = ix.list_managed() + started = [c for c in managed if c["name"] in started_cnames] + + if started: + print( + f"Resetting DNS zones for {len(started)} domain(s) (A + AAAA records) ..." + ) + dns_ct.reset_dns_records(dns_ip, started) + + for ct in relays: + if ct.name in started_cnames: + print(f" Configuring DNS in {ct.name} ...") + ct.configure_dns(dns_ip) + + # Generate the unified SSH config + out.green("Writing ssh-config ...") + ssh_cfg = ix.write_ssh_config() + print(f" {ssh_cfg}") + + # Verify SSH via the generated config + for ct in relays: + print(f" Verifying SSH to {ct.name} via ssh-config ...") + if ct.verify_ssh(ssh_cfg): + print(f" SSH OK: ssh -F lxconfigs/ssh-config {ct.domain}") + else: + out.red(f" WARNING: SSH verification failed for {ct.name}") + + # Print integration suggestions + ssh_cfg = ix.ssh_config_path + if not ix.check_ssh_include(): + out.green( + "\n (Optional) To use containers from any SSH client, add to ~/.ssh/config:" + ) + out.green(f" Include {ssh_cfg}") + + # Optionally run cmdeploy run on each relay + if args.run: + for ct in relays: + with _section(out, f"cmdeploy run: {ct.sname} ({ct.domain})"): + ret = _run_cmdeploy("run", ct, ix, extra=["--skip-dns-check"]) + if ret: + out.red(f"Deploy to {ct.sname} failed (exit {ret})") + return ret + + +# ------------------------------------------------------------------- +# lxc-stop +# ------------------------------------------------------------------- + + +def lxc_stop_cmd_options(parser): + parser.add_argument( + "--destroy", + action="store_true", + help="Delete containers and their config files after stopping.", + ) + parser.add_argument( + "--destroy-all", + dest="destroy_all", + action="store_true", + help="Like --destroy, but also remove the ns-localchat DNS container.", + ) + _add_name_args( + parser, + help_text="Container name(s) to stop (default: test0 + test1).", + ) + + +def lxc_stop_cmd(args, out): + """Stop (and optionally destroy) local LXC relay containers.""" + ix = Incus() + names = args.names or RELAY_NAMES + destroy = args.destroy or args.destroy_all + + for ct in map(ix.get_container, names): + if destroy: + out.green(f"Destroying container {ct.name!r} ...") + ct.destroy() + else: + out.green(f"Stopping container {ct.name!r} ...") + ct.stop(force=True) + + if args.destroy_all: + dns_ct = ix.get_dns_container() + out.green(f"Destroying DNS container {dns_ct.name!r} ...") + dns_ct.destroy() + ix.delete_images() + + if destroy: + ix.write_ssh_config() + out.green("LXC containers destroyed.") + else: + out.green("LXC containers stopped.") + + +# ------------------------------------------------------------------- +# lxc-test +# ------------------------------------------------------------------- + + +def lxc_test_cmd_options(parser): + parser.add_argument( + "--one", + action="store_true", + help="Only deploy and test against test0 (skip test1).", + ) + + +def lxc_test_cmd(args, out): + """Run full LXC pipeline: start, deploy, DNS, zone files, and tests. + + All commands run directly on the host using + ``--ssh-config lxconfigs/ssh-config`` for SSH access. + """ + ix = Incus() + t_total = time.time() + relay_names = list(RELAY_NAMES) + if args.one: + relay_names = relay_names[:1] + + local_hash = get_git_hash() + + # Per-relay: start, deploy, then snapshot the first relay as a + # reusable image so the second relay launches pre-deployed. + ipv4_only_flags = {RELAY_NAMES[0]: False, RELAY_NAMES[1]: True} + + for ct in map(ix.get_container, relay_names): + name = ct.sname + ipv4_only = ipv4_only_flags.get(name, False) + label = "IPv4-only" if ipv4_only else "dual-stack" + + with _section(out, f"LXC: lxc-start {name} ({label})"): + args.names = [name] + args.ipv4_only = ipv4_only + args.run = False + ret = lxc_start_cmd(args, out) + if ret: + return ret + + status = _deploy_status(ct, local_hash, ix) + if "IN-SYNC" in status: + _section_line(out, f"cmdeploy run: {name} — {status}, skipping") + else: + with _section(out, f"cmdeploy run: {name} ({ct.domain})"): + ret = _run_cmdeploy("run", ct, ix, extra=["--skip-dns-check"]) + if ret: + out.red(f"Deploy to {name} failed (exit {ret})") + return ret + + # Snapshot the first relay so subsequent ones launch pre-deployed + if not ix.find_relay_image(): + with _section(out, "LXC: publishing relay image"): + ct.publish_as_relay_image() + + for ct in map(ix.get_container, relay_names): + with _section(out, f"cmdeploy dns: {ct.sname} ({ct.domain})"): + ret = _run_cmdeploy("dns", ct, ix, extra=["--zonefile", str(ct.zone)]) + if ret: + out.red(f"DNS for {ct.sname} failed (exit {ret})") + return ret + + with _section(out, "LXC: PowerDNS zone update"): + dns_ct = ix.get_dns_container() + for ct in map(ix.get_container, relay_names): + if ct.zone.exists(): + zone_data = ct.zone.read_text() + print(f" Loading {ct.zone} into PowerDNS ...") + dns_ct.set_dns_records(zone_data) + + with _section(out, "cmdeploy test"): + first = ix.get_container(relay_names[0]) + env = None + if len(relay_names) > 1: + env = os.environ.copy() + env["CHATMAIL_DOMAIN2"] = ix.get_container(relay_names[1]).domain + ret = _run_cmdeploy("test", first, ix, **({"env": env} if env else {})) + if ret: + out.red(f"Tests failed (exit {ret})") + return ret + + elapsed = time.time() - t_total + _section_line(out, f"lxc-test complete ({elapsed:.1f}s)") + return 0 + + +# ------------------------------------------------------------------- +# lxc-status +# ------------------------------------------------------------------- + + +def lxc_status_cmd_options(parser): + pass + + +def lxc_status_cmd(args, out): + """Show status of local LXC chatmail containers.""" + ix = Incus() + containers = ix.list_managed() + if not containers: + out.red("No LXC containers found. Run 'cmdeploy lxc-start' first.") + return 1 + + local_hash = get_git_hash() + + # Get storage pool path for display + storage_path = None + data = ix.run_json(["storage", "show", "default"], check=False) + if data: + storage_path = data.get("config", {}).get("source") + if storage_path: + out.green(f"Containers: ({storage_path})") + else: + out.green("Containers:") + + dns_ip = None + for c in containers: + _print_container_status(c, ix, local_hash) + if c["name"] == ix.get_dns_container().name: + dns_ip = c["ip"] + + _print_ssh_status(out, ix) + _print_dns_forwarding_status(out, dns_ip) + return 0 + + +def _print_container_status(c, ix, local_hash): + """Print name/status, domain/IPs, and RAM for one container.""" + cname = c["name"] + is_running = c.get("status") == "Running" + ct = ix.get_container(cname) + + # First line: name + running/STOPPED + deploy status + if not is_running: + tag = "STOPPED" + elif not isinstance(ct, RelayContainer): + tag = "running" + else: + tag = f"running {_deploy_status(ct, local_hash, ix)}" + print(f" {cname:20s} {tag}") + + # Second line: domain, IPv4, IPv6 + domain = c.get("domain", "") + ip = c.get("ip") or "?" + ipv6 = c.get("ipv6") + print(f" {domain:20s} {_format_addrs(ip, ipv6)}") + + # Third line: RAM (RSS), config + indent = " " * 21 + try: + used, total = ct.rss_mib() + except Exception: + ram_str = "RSS ?" + else: + ram_str = f"RSS {used}/{total} MiB ({used * 100 // total}%)" + + if isinstance(ct, RelayContainer): + detail = f"{ram_str}, config: {os.path.relpath(ct.ini)}" + else: + detail = ram_str + + print(f" {indent}{detail}") + print() + + +def _print_ssh_status(out, ix): + """Print SSH integration status.""" + print() + ssh_cfg = ix.ssh_config_path + if ix.check_ssh_include(): + out.green("SSH: ~/.ssh/config includes lxconfigs/ssh-config ✓") + else: + out.red("SSH: ~/.ssh/config does NOT include lxconfigs/ssh-config") + print(" Add to ~/.ssh/config:") + print(f" Include {ssh_cfg}") + + +def _print_dns_forwarding_status(out, dns_ip): + """Print host DNS forwarding status for .localchat.""" + if not dns_ip: + out.red("DNS: ns-localchat container not found") + return + try: + rv = shell("resolvectl status incusbr0", timeout=5) + dns_ok = dns_ip in rv.stdout and "localchat" in rv.stdout + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + dns_ok = None + if dns_ok is True: + out.green(f"DNS: .localchat forwarding to {dns_ip} ✓") + elif dns_ok is False: + out.red("DNS: .localchat forwarding NOT configured") + print(" Run:") + print(f" sudo resolvectl dns incusbr0 {dns_ip}") + print(" sudo resolvectl domain incusbr0 ~localchat") + else: + print(" DNS: .localchat forwarding status UNKNOWN") + + +# ------------------------------------------------------------------- +# Internal helpers +# ------------------------------------------------------------------- + + +def _format_addrs(ip, ipv6=None): + parts = [f"IPv4 {ip}"] + if ipv6: + parts.append(f"IPv6 {ipv6}") + return ", ".join(parts) + + +SECTION_WIDTH = 72 + + +@contextmanager +def _section(out, title): + bar = "\u2501" * (SECTION_WIDTH - len(title) - 5) + out.green(f"\u2501\u2501\u2501 {title} {bar}") + t0 = time.time() + yield + elapsed = time.time() - t0 + print(f"{'':>{SECTION_WIDTH - 10}}({elapsed:.1f}s)") + print() + + +def _section_line(out, title): + bar = "\u2501" * (SECTION_WIDTH - len(title) - 5) + out.green(f"\u2501\u2501\u2501 {title} {bar}") + print() + + +def _deploy_status(ct, local_hash, ix): + """Return a human-readable deploy status string. + + Compares the full deployed version (hash + diff) against + the local state built by :func:`~cmdeploy.util.get_version_string`. + """ + deployed = ct.deployed_version() + if deployed is None: + return "NOT DEPLOYED" + + # A container launched from the relay image has the same + # git hash but a different domain — always redeploy. + deployed_domain = ct.deployed_domain() + if deployed_domain and deployed_domain != ct.domain: + return f"DOMAIN-MISMATCH (deployed: {deployed_domain})" + + deployed_lines = deployed.splitlines() + deployed_hash = deployed_lines[0] if deployed_lines else "" + short = deployed_hash[:12] + + if not local_hash: + return f"UNKNOWN (deployed: {short})" + + local_short = local_hash[:12] + if deployed_hash != local_hash: + return f"STALE (deployed: {short}, local: {local_short})" + + # Hash matches — check for uncommitted diffs + local_version = get_version_string() + if deployed != local_version: + return f"DIRTY ({local_short}, undeployed changes)" + + return f"IN-SYNC ({short})" + + +def _add_name_args(parser, help_text=None): + """Add optional positional NAME arguments.""" + parser.add_argument( + "names", + nargs="*", + metavar="NAME", + help=help_text or "Relay name(s) to operate on.", + ) + + +def _run_cmdeploy(subcmd, ct, ix, extra=None, **kwargs): + """Run ``cmdeploy `` with standard --config/--ssh flags. + + *ct* is a Container (uses ``ct.ini`` and ``ct.domain``). + Returns the subprocess exit code. + """ + extra_str = " ".join(extra) if extra else "" + cmd = f"""\ + cmdeploy {subcmd} + --config {ct.ini} + --ssh-config {ix.ssh_config_path} + --ssh-host {ct.domain} + {extra_str} + """ + if "cwd" not in kwargs: + kwargs["cwd"] = str(ix.project_root) + cmd = collapse(cmd) + print(f" [$ {cmd}]") + return shell(cmd, capture_output=False, **kwargs).returncode diff --git a/cmdeploy/src/cmdeploy/lxc/incus.py b/cmdeploy/src/cmdeploy/lxc/incus.py new file mode 100644 index 000000000..59d7a3315 --- /dev/null +++ b/cmdeploy/src/cmdeploy/lxc/incus.py @@ -0,0 +1,638 @@ +"""Core Incus operations for local chatmail LXC containers.""" + +import json +import subprocess +import textwrap +import time +from pathlib import Path + +from ..util import shell + +LABEL_KEY = "user.localchat-managed" +SSH_KEY_NAME = "id_localchat" +DOMAIN_SUFFIX = ".localchat" +UPSTREAM_IMAGE = "images:debian/12" +BASE_IMAGE_ALIAS = "localchat-base" +BASE_SETUP_NAME = "localchat-base-setup" +RELAY_IMAGE_ALIAS = "localchat-relay" + +DNS_CONTAINER_NAME = "ns-localchat" +DNS_DOMAIN = "ns.localchat" + + +def _extract_ip(net_data, family="inet"): + """Extract the first global-scope IP of *family* from network state data. + + *net_data* is the ``state.network`` dict from ``incus list --format=json``. + *family* is ``"inet"`` for IPv4 or ``"inet6"`` for IPv6. + Returns the address string, or None. + """ + for iface_name, iface in net_data.items(): + if iface_name == "lo": + continue + for addr in iface.get("addresses", []): + if addr["family"] == family and addr["scope"] == "global": + return addr["address"] + return None + + +class Incus: + """Gateway for all Incus container operations. + + Instantiated once per CLI command and passed around so that + all modules share a single entry point for Incus interactions. + """ + + def __init__(self): + self.project_root = Path(__file__).resolve().parent.parent.parent.parent.parent + self.lxconfigs_dir = self.project_root / "lxconfigs" + self.lxconfigs_dir.mkdir(exist_ok=True) + self.ssh_key_path = self.lxconfigs_dir / SSH_KEY_NAME + if not self.ssh_key_path.exists(): + shell( + f"ssh-keygen -t ed25519 -f {self.ssh_key_path} -N '' -C localchat", + check=True, + ) + self.ssh_config_path = self.lxconfigs_dir / "ssh-config" + + def write_ssh_config(self): + """Write ``lxconfigs/ssh-config`` mapping all containers to their IPs. + + Each Host block maps the container name, the domain name, and the + short relay name (e.g. ``_test0``) to the container's IP, using the + shared localchat SSH key. Returns the path to the file. + """ + containers = self.list_managed() + key_path = self.ssh_key_path + lines = ["# Auto-generated by cmdeploy lxc-start — do not edit\n"] + for c in containers: + hosts = [c["name"]] + domain = c.get("domain", "") + if domain and domain != c["name"]: + hosts.append(domain) + short = domain.split(".")[0] + if short and short not in hosts: + hosts.append(short) + lines.append(f"\nHost {' '.join(hosts)}\n") + lines.append(f" Hostname {c['ip']}\n") + lines.append(" User root\n") + lines.append(f" IdentityFile {key_path}\n") + lines.append(" IdentitiesOnly yes\n") + lines.append(" StrictHostKeyChecking accept-new\n") + lines.append(" UserKnownHostsFile /dev/null\n") + lines.append(" LogLevel ERROR\n") + path = self.ssh_config_path + path.write_text("".join(lines)) + return path + + def check_ssh_include(self): + """Check if the user's ~/.ssh/config already includes our ssh-config.""" + user_ssh_config = Path.home() / ".ssh" / "config" + if not user_ssh_config.exists(): + return False + lines = filter(None, map(str.strip, user_ssh_config.open("r"))) + return f"Include {self.ssh_config_path}" in lines + + def run(self, args, check=True, capture=True, input=None): + """Run an incus command.""" + cmd = ["incus"] + list(args) + kwargs = dict(check=check, text=True, input=input) + if capture: + kwargs["capture_output"] = True + else: + kwargs["stdout"] = None + kwargs["stderr"] = None + return subprocess.run(cmd, **kwargs) # noqa: PLW1510 + + def run_json(self, args, check=True): + """Run an incus command with ``--format=json``. + + Returns the parsed JSON on success. + When *check* is True raises ``subprocess.CalledProcessError`` + on non-zero exit; when False returns *None* instead. + """ + result = self.run( + list(args) + ["--format=json"], + check=check, + ) + if result.returncode != 0: + return None + return json.loads(result.stdout) + + def run_output(self, args, check=True): + """Run an incus command and return its stdout. + + When *check* is False, returns *None* on non-zero exit + instead of raising. + """ + result = self.run(args, check=check) + if result.returncode != 0: + return None + return result.stdout + + def _find_image(self, alias): + """Return *alias* if an image with that alias exists, else None.""" + images = self.run_json(["image", "list"], check=False) or [] + for img in images: + for a in img.get("aliases", []): + if a.get("name") == alias: + return alias + return None + + def find_relay_image(self): + """Return the relay image alias if it exists, else None.""" + return self._find_image(RELAY_IMAGE_ALIAS) + + def delete_images(self): + """Delete the cached base and relay images.""" + for alias in (RELAY_IMAGE_ALIAS, BASE_IMAGE_ALIAS): + self.run(["image", "delete", alias], check=False) + + def list_managed(self): + """Return list of dicts with name, ip, ipv6, domain, status, memory_usage.""" + containers = [] + for ct in self.run_json(["list"]): + config = ct.get("config", {}) + if config.get(LABEL_KEY) != "true": + continue + name = ct["name"] + state = ct.get("state", {}) + net = state.get("network") or {} + containers.append( + { + "name": name, + "ip": _extract_ip(net, "inet"), + "ipv6": _extract_ip(net, "inet6"), + "domain": config.get( + "user.localchat-domain", f"{name}{DOMAIN_SUFFIX}" + ), + "status": ct.get("status", "Unknown"), + "memory_usage": state.get("memory", {}).get("usage", 0), + } + ) + return containers + + def ensure_base_image(self): + """Build and cache a base image with openssh and the SSH key. + + The image is published as a local incus image with alias + 'localchat-base'. Subsequent container launches use this + image instead of the upstream Debian 12, skipping the + slow apt-get install step. + Returns the image alias. + """ + if self._find_image(BASE_IMAGE_ALIAS): + return BASE_IMAGE_ALIAS + + print(" Building base image (one-time setup) ...") + + self.run(["delete", BASE_SETUP_NAME, "--force"], check=False) + self.run(["image", "delete", BASE_IMAGE_ALIAS], check=False) + self.run(["launch", UPSTREAM_IMAGE, BASE_SETUP_NAME]) + + ct = Container(self, BASE_SETUP_NAME) + ct.wait_ready() + + key_path = self.ssh_key_path + pub_key = key_path.with_suffix(".pub").read_text().strip() + ct.bash(f"""\ + apt-get -o DPkg::Lock::Timeout=60 update + DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server python3 + systemctl enable ssh + apt-get clean + mkdir -p /root/.ssh + chmod 700 /root/.ssh + echo '{pub_key}' > /root/.ssh/authorized_keys + chmod 600 /root/.ssh/authorized_keys + """) + + self.run(["stop", BASE_SETUP_NAME]) + self.run(["publish", BASE_SETUP_NAME, f"--alias={BASE_IMAGE_ALIAS}"]) + self.run(["delete", BASE_SETUP_NAME, "--force"]) + print(f" Base image '{BASE_IMAGE_ALIAS}' ready.") + return BASE_IMAGE_ALIAS + + def get_container(self, name): + """Return a container handle for the given name. + + Accepts both short relay names (``test0``) and full Incus + container names (``test0-localchat``). Returns + ``DNSContainer`` for the DNS container and + ``RelayContainer`` for everything else. + """ + if name == DNS_CONTAINER_NAME: + return DNSContainer(self) + return RelayContainer(self, name.removesuffix("-localchat")) + + def get_dns_container(self): + """Return a DNSContainer handle.""" + return DNSContainer(self) + + +class Container: + """Lightweight handle for an Incus container. + + Carries the container *name* and provides convenience methods + for running commands, managing lifecycle, and extracting state + so callers don't repeat the name everywhere. + """ + + def __init__(self, incus, name, domain=None, memory="100MiB"): + self.incus = incus + self.name = name + self.domain = domain or f"{name}{DOMAIN_SUFFIX}" + self.memory = memory + self.ipv4 = None + self.ipv6 = None + + def bash(self, script, check=True): + """Returns stdout from executing ``bash -ec