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.
This commit is contained in:
holger krekel
2026-03-07 14:40:24 +01:00
parent 4b79606d49
commit 735e9d3e7f
6 changed files with 148 additions and 90 deletions

View File

@@ -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:

View File

@@ -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 <subcmd>`` 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: