From 735e9d3e7f01cd363032d3ce081833b491c21f2a Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 7 Mar 2026 14:40:24 +0100 Subject: [PATCH] feat: route output through Out, add DNS check and version-string excludes Extend the Out class with section(), section_line(), and print() methods that replace the standalone _section()/_section_line() helpers. Section timings are recorded and printed as a summary at the end of lxc-test. Add RelayContainer.check_dns() which retries 'getent hosts pypi.org' to verify external DNS resolution works inside the container, called right after configure_dns() in lxc-start. Add DIFF_EXCLUDES to get_version_string() so that diffs limited to test directories do not cause a version mismatch and unnecessary redeployment. Update test_lxc.py to use QuietOut for the new Out API. --- cmdeploy/src/cmdeploy/cmdeploy.py | 37 +++++- cmdeploy/src/cmdeploy/lxc/cli.py | 148 +++++++++++------------ cmdeploy/src/cmdeploy/lxc/incus.py | 15 ++- cmdeploy/src/cmdeploy/tests/test_lxc.py | 4 +- cmdeploy/src/cmdeploy/tests/test_util.py | 12 +- cmdeploy/src/cmdeploy/util.py | 22 +++- 6 files changed, 148 insertions(+), 90 deletions(-) diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index 27854b7c..9c5cd8b0 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -10,6 +10,8 @@ import pathlib import shutil import subprocess import sys +import time +from contextlib import contextmanager from pathlib import Path import pyinfra @@ -330,17 +332,44 @@ def webdev_cmd(args, out): class Out: - """Convenience output printer providing coloring.""" + """Convenience output printer providing coloring and section formatting.""" + + SECTION_WIDTH = 72 + + def __init__(self): + self.section_timings = [] def red(self, msg, file=sys.stderr): - print(colored(msg, "red"), file=file) + print(colored(msg, "red"), file=file, flush=True) def green(self, msg, file=sys.stderr): - print(colored(msg, "green"), file=file) + print(colored(msg, "green"), file=file, flush=True) + + def print(self, msg="", **kwargs): + """Print to stdout with automatic flush.""" + print(msg, flush=True, **kwargs) + + @contextmanager + def section(self, title): + """Context manager that prints a section header and records elapsed time.""" + bar = "\u2501" * (self.SECTION_WIDTH - len(title) - 5) + self.green(f"\u2501\u2501\u2501 {title} {bar}") + t0 = time.time() + yield + elapsed = time.time() - t0 + self.section_timings.append((title, elapsed)) + self.print(f"{'':>{self.SECTION_WIDTH - 10}}({elapsed:.1f}s)") + self.print() + + def section_line(self, title): + """Print a section header without timing.""" + bar = "\u2501" * (self.SECTION_WIDTH - len(title) - 5) + self.green(f"\u2501\u2501\u2501 {title} {bar}") + self.print() def __call__(self, msg, red=False, green=False, file=sys.stdout): color = "red" if red else ("green" if green else None) - print(colored(msg, color), file=file) + print(colored(msg, color), file=file, flush=True) def check_call(self, arg, env=None, quiet=False): if not quiet: diff --git a/cmdeploy/src/cmdeploy/lxc/cli.py b/cmdeploy/src/cmdeploy/lxc/cli.py index bdef72a8..a4347dad 100644 --- a/cmdeploy/src/cmdeploy/lxc/cli.py +++ b/cmdeploy/src/cmdeploy/lxc/cli.py @@ -4,7 +4,6 @@ import os import subprocess import threading import time -from contextlib import contextmanager from ..util import ( collapse, @@ -47,9 +46,9 @@ def lxc_start_cmd(args, out): dns_ct = ix.get_dns_container() dns_ct.ensure() if not ix.find_dns_image(): - with _section(out, "LXC: publishing DNS image"): + with out.section("LXC: publishing DNS image"): dns_ct.publish_as_dns_image() - print(f" DNS container IP: {dns_ct.ipv4}") + out.print(f" DNS container IP: {dns_ct.ipv4}") names = args.names if args.names else RELAY_NAMES relays = list(ix.get_container(n) for n in names) @@ -58,12 +57,12 @@ def lxc_start_cmd(args, out): ct.ensure() ip = ct.ipv4 - print(" Configuring container hostname ...") + out.print(" Configuring container hostname ...") ct.configure_hosts(ip) - print(f" Writing {ct.ini.name} ...") + out.print(f" Writing {ct.ini.name} ...") ct.write_ini(disable_ipv6=args.ipv4_only) - print(f" Config: {ct.ini}") + out.print(f" Config: {ct.ini}") if args.ipv4_only: ct.disable_ipv6() ipv6 = None @@ -74,10 +73,10 @@ def lxc_start_cmd(args, out): check=False, ) ipv6 = output.strip() if output else None - print(f" {_format_addrs(ip, ipv6)}") + out.print(f" {_format_addrs(ip, ipv6)}") out.green(f" Container {ct.name!r} ready: {ct.domain} -> {ip}") - print() + out.print() # Reset DNS zones only for the containers we just started started_cnames = {ct.name for ct in relays} @@ -85,26 +84,33 @@ def lxc_start_cmd(args, out): 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) ..." + out.print( + f"Resetting DNS zones for {len(started)}" + " domain(s) (A + AAAA records) ..." ) dns_ct.reset_dns_records(dns_ct.ipv4, started) for ct in relays: if ct.name in started_cnames: - print(f" Configuring DNS in {ct.name} ...") + out.print(f" Configuring and testing DNS in {ct.name} ...") ct.configure_dns(dns_ct.ipv4) + if not ct.check_dns(): + out.red( + f" DNS check failed for {ct.name}" + ": cannot resolve external hosts" + ) + return 1 # Generate the unified SSH config out.green("Writing ssh-config ...") ssh_cfg = ix.write_ssh_config() - print(f" {ssh_cfg}") + out.print(f" {ssh_cfg}") # Verify SSH via the generated config for ct in relays: - print(f" Verifying SSH to {ct.name} via ssh-config ...") + out.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}") + out.print(f" SSH OK: ssh -F lxconfigs/ssh-config {ct.domain}") else: out.red(f" WARNING: SSH verification failed for {ct.name}") @@ -119,8 +125,8 @@ def lxc_start_cmd(args, out): # 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"]) + with out.section(f"cmdeploy run: {ct.sname} ({ct.domain})"): + ret = _run_cmdeploy("run", ct, ix, out, extra=["--skip-dns-check"]) if ret: out.red(f"Deploy to {ct.sname} failed (exit {ret})") return ret @@ -209,13 +215,13 @@ def lxc_test_cmd(args, out): # Per-relay: start containers, then deploy in parallel. ipv4_only_flags = {RELAY_NAMES[0]: False, RELAY_NAMES[1]: True} - # Phase 1 — start all containers (sequential, fast) + # Phase 1: start all containers (sequential, fast) 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})"): + with out.section(f"LXC: lxc-start {name} ({label})"): args.names = [name] args.ipv4_only = ipv4_only args.run = False @@ -223,48 +229,45 @@ def lxc_test_cmd(args, out): if ret: return ret - # Phase 2 — deploy all relays in parallel + # Phase 2: deploy all relays in parallel to_deploy = [] for ct in map(ix.get_container, relay_names): status = _deploy_status(ct, local_hash, ix) if "IN-SYNC" in status: - _section_line( - out, f"cmdeploy run: {ct.sname} — {status}, skipping" - ) + out.section_line(f"cmdeploy run: {ct.sname}: {status}, skipping") else: to_deploy.append(ct) if to_deploy: - with _section(out, "cmdeploy run (parallel)"): + with out.section("cmdeploy run (parallel)"): ret = _run_cmdeploy_parallel( "run", to_deploy, ix, out, extra=["--skip-dns-check"] ) if ret: return ret - # Phase 3 — publish images (sequential, fast) + # Phase 3: publish images (sequential, fast) for ct in map(ix.get_container, relay_names): if ct.publish_image(): - _section_line(out, f"LXC: published {ct.sname} image") + out.section_line(f"LXC: published {ct.sname} image") else: - _section_line( - out, - f"LXC: publish {ct.sname} image — skipped, cached", + out.section_line( + f"LXC: publish {ct.sname} image: skipped, cached", ) 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)]) + with out.section(f"cmdeploy dns: {ct.sname} ({ct.domain})"): + ret = _run_cmdeploy("dns", ct, ix, out, 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"): + with out.section("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 ...") + out.print(f" Loading {ct.zone} into PowerDNS ...") dns_ct.set_dns_records(zone_data) # Run tests in both directions when two relays are available. @@ -279,14 +282,20 @@ def lxc_test_cmd(args, out): env = os.environ.copy() env["CHATMAIL_DOMAIN2"] = second.domain - with _section(out, f"cmdeploy test: {label}"): - ret = _run_cmdeploy("test", first, ix, **({"env": env} if env else {})) + with out.section(f"cmdeploy test: {label}"): + ret = _run_cmdeploy("test", first, ix, out, **({"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)") + out.section_line(f"lxc-test complete ({elapsed:.1f}s)") + if out.section_timings: + out.print("Section timings:") + for name, secs in out.section_timings: + out.print(f" {name:.<50s} {secs:5.1f}s") + out.print(f" {'total':.<50s} {elapsed:5.1f}s") + out.section_timings.clear() return 0 @@ -321,7 +330,7 @@ def lxc_status_cmd(args, out): dns_ip = None for c in containers: - _print_container_status(c, ix, local_hash) + _print_container_status(out, c, ix, local_hash) if c["name"] == ix.get_dns_container().name: dns_ip = c["ip"] @@ -330,7 +339,7 @@ def lxc_status_cmd(args, out): return 0 -def _print_container_status(c, ix, local_hash): +def _print_container_status(out, c, ix, local_hash): """Print name/status, domain/IPs, and RAM for one container.""" cname = c["name"] is_running = c.get("status") == "Running" @@ -343,13 +352,13 @@ def _print_container_status(c, ix, local_hash): tag = "running" else: tag = f"running {_deploy_status(ct, local_hash, ix)}" - print(f" {cname:20s} {tag}") + out.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)}") + out.print(f" {domain:20s} {_format_addrs(ip, ipv6)}") # Third line: RAM (RSS), config indent = " " * 21 @@ -365,20 +374,20 @@ def _print_container_status(c, ix, local_hash): else: detail = ram_str - print(f" {indent}{detail}") - print() + out.print(f" {indent}{detail}") + out.print() def _print_ssh_status(out, ix): """Print SSH integration status.""" - print() + out.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}") + out.print(" Add to ~/.ssh/config:") + out.print(f" Include {ssh_cfg}") def _print_dns_forwarding_status(out, dns_ip): @@ -395,11 +404,11 @@ def _print_dns_forwarding_status(out, dns_ip): 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") + out.print(" Run:") + out.print(f" sudo resolvectl dns incusbr0 {dns_ip}") + out.print(" sudo resolvectl domain incusbr0 ~localchat") else: - print(" DNS: .localchat forwarding status UNKNOWN") + out.print(" DNS: .localchat forwarding status UNKNOWN") # ------------------------------------------------------------------- @@ -414,26 +423,6 @@ def _format_addrs(ip, ipv6=None): 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. @@ -445,7 +434,7 @@ def _deploy_status(ct, local_hash, ix): return "NOT DEPLOYED" # A container launched from the relay image has the same - # git hash but a different domain — always redeploy. + # 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})" @@ -461,7 +450,7 @@ def _deploy_status(ct, local_hash, ix): if deployed_hash != local_hash: return f"STALE (deployed: {short}, local: {local_short})" - # Hash matches — check for uncommitted diffs + # Hash matches - check for uncommitted diffs local_version = get_version_string() if deployed != local_version: return f"DIRTY ({local_short}, undeployed changes)" @@ -491,7 +480,7 @@ def _build_cmdeploy_cmd(subcmd, ct, ix, extra=None): """) -def _run_cmdeploy(subcmd, ct, ix, extra=None, **kwargs): +def _run_cmdeploy(subcmd, ct, ix, out, extra=None, **kwargs): """Run ``cmdeploy `` with standard --config/--ssh flags. *ct* is a Container (uses ``ct.ini`` and ``ct.domain``). @@ -500,7 +489,7 @@ def _run_cmdeploy(subcmd, ct, ix, extra=None, **kwargs): cmd = _build_cmdeploy_cmd(subcmd, ct, ix, extra=extra) if "cwd" not in kwargs: kwargs["cwd"] = str(ix.project_root) - print(f" [$ {cmd}]") + out.print(f" [$ {cmd}]") return shell(cmd, capture_output=False, **kwargs).returncode @@ -521,7 +510,7 @@ def _run_cmdeploy_parallel(subcmd, containers, ix, out, extra=None): for ct in containers: cmd = _build_cmdeploy_cmd(subcmd, ct, ix, extra=extra) - print(f" [{ct.sname}] $ {cmd}") + out.print(f" [{ct.sname}] $ {cmd}") proc = subprocess.Popen( cmd, shell=True, @@ -538,12 +527,14 @@ def _run_cmdeploy_parallel(subcmd, containers, ix, out, extra=None): line = raw.rstrip("\n") lines.append(line) if "Starting operation" in line: - print(f"{prefix} {line}") + out.print(f"{prefix} {line}") threads = [] for ct, proc, lines in procs: t = threading.Thread( - target=_reader, args=(ct, proc, lines), daemon=True, + target=_reader, + args=(ct, proc, lines), + daemon=True, ) t.start() threads.append(t) @@ -557,13 +548,10 @@ def _run_cmdeploy_parallel(subcmd, containers, ix, out, extra=None): first_failure = 0 for ct, proc, lines in procs: if proc.returncode: - out.red( - f"Deploy to {ct.sname} failed " - f"(exit {proc.returncode})" - ) + out.red(f"Deploy to {ct.sname} failed " f"(exit {proc.returncode})") tail = lines[-_FAIL_CONTEXT_LINES:] for tl in tail: - print(f" [{ct.sname}] {tl}") + out.print(f" [{ct.sname}] {tl}") if not first_failure: first_failure = proc.returncode diff --git a/cmdeploy/src/cmdeploy/lxc/incus.py b/cmdeploy/src/cmdeploy/lxc/incus.py index e54da7c0..c02cf5c0 100644 --- a/cmdeploy/src/cmdeploy/lxc/incus.py +++ b/cmdeploy/src/cmdeploy/lxc/incus.py @@ -72,7 +72,7 @@ class Incus: """ containers = self.list_managed() key_path = self.ssh_key_path - lines = ["# Auto-generated by cmdeploy lxc-start — do not edit\n"] + lines = ["# Auto-generated by cmdeploy lxc-start - do not edit\n"] for c in containers: hosts = [c["name"]] domain = c.get("domain", "") @@ -533,6 +533,19 @@ class RelayContainer(Container): systemctl restart unbound 2>/dev/null || true """) + def check_dns(self, retries=5, delay=2): + """Verify that external DNS resolution works inside the container.""" + for i in range(retries): + result = self.bash( + "getent hosts pypi.org", + check=False, + ) + if result: + return True + if i < retries - 1: + time.sleep(delay) + return False + def write_ini(self, disable_ipv6=False): """Generate a chatmail.ini config file in lxconfigs/.""" from chatmaild.config import write_initial_config diff --git a/cmdeploy/src/cmdeploy/tests/test_lxc.py b/cmdeploy/src/cmdeploy/tests/test_lxc.py index 504e1f4a..7a3285e5 100644 --- a/cmdeploy/src/cmdeploy/tests/test_lxc.py +++ b/cmdeploy/src/cmdeploy/tests/test_lxc.py @@ -126,7 +126,9 @@ class TestLxcStatus: assert "status" in result.stdout.lower() def test_shows_containers(self, lxc_setup, capsys): - class QuietOut: + from cmdeploy.cmdeploy import Out + + class QuietOut(Out): def red(self, msg, **kw): pass diff --git a/cmdeploy/src/cmdeploy/tests/test_util.py b/cmdeploy/src/cmdeploy/tests/test_util.py index 7d609da5..c5794455 100644 --- a/cmdeploy/src/cmdeploy/tests/test_util.py +++ b/cmdeploy/src/cmdeploy/tests/test_util.py @@ -61,6 +61,16 @@ def test_git_helpers_with_commits_and_diffs(tmp_path): assert new_hash != git_hash assert get_version_string(root=tmp_path) == new_hash + # Diffs inside excluded test dirs are invisible to the version string + test_dir = tmp_path / "cmdeploy" / "src" / "cmdeploy" / "tests" + test_dir.mkdir(parents=True) + test_file = test_dir / "test_foo.py" + test_file.write_text("pass") + shell("git add .", cwd=tmp_path, check=True) + shell("git commit -m 'add test file'", cwd=tmp_path, check=True) + test_file.write_text("assert True") + assert get_version_string(root=tmp_path) == get_git_hash(root=tmp_path) + def test_build_chatmaild_sdist(tmp_path): dist_dir = tmp_path / "dist" @@ -70,7 +80,7 @@ def test_build_chatmaild_sdist(tmp_path): assert result.name.endswith(".tar.gz") assert result.stat().st_size > 0 - # Second call is idempotent — returns the same file, no rebuild + # Second call is idempotent - returns the same file, no rebuild mtime = result.stat().st_mtime result2 = build_chatmaild_sdist(dist_dir) assert result2 == result diff --git a/cmdeploy/src/cmdeploy/util.py b/cmdeploy/src/cmdeploy/util.py index 4a75ff4b..8d0e9058 100644 --- a/cmdeploy/src/cmdeploy/util.py +++ b/cmdeploy/src/cmdeploy/util.py @@ -17,11 +17,11 @@ def collapse(text): Handy for writing shell commands across multiple lines:: - cmd = collapse(f\"\"\" + cmd = collapse(f\""" cmdeploy run --config {ct.ini} --ssh-host {ct.domain} - \"\"\") + \""") """ return textwrap.dedent(text).replace("\n", " ").strip() @@ -52,18 +52,34 @@ def get_git_hash(root=None): return None +DIFF_EXCLUDES = ( + ":(exclude)cmdeploy/src/cmdeploy/tests", + ":(exclude)chatmaild/src/chatmaild/tests", +) +"""Git pathspecs appended to ``git diff`` so that changes +limited to test files do not affect the deployed version string.""" + + def get_version_string(root=None): """Return ``git_hash\\ngit_diff`` for the local working tree. Used by :class:`~cmdeploy.deployers.GithashDeployer` to write ``/etc/chatmail-version`` and by ``lxc-status`` to compare the deployed state against the local checkout. + + Changes inside directories listed in :data:`DIFF_EXCLUDES` + are ignored so that test-only edits do not trigger + a redeployment. """ if root is None: root = _project_root() git_hash = get_git_hash(root=root) or "unknown" + excludes = " ".join(f"'{e}'" for e in DIFF_EXCLUDES) try: - git_diff = shell("git diff", cwd=str(root)).stdout.strip() + git_diff = shell( + f"git diff -- . {excludes}", + cwd=str(root), + ).stdout.strip() except Exception: git_diff = "" if git_diff: