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 shutil
import subprocess import subprocess
import sys import sys
import time
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
import pyinfra import pyinfra
@@ -330,17 +332,44 @@ def webdev_cmd(args, out):
class 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): 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): 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): def __call__(self, msg, red=False, green=False, file=sys.stdout):
color = "red" if red else ("green" if green else None) 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): def check_call(self, arg, env=None, quiet=False):
if not quiet: if not quiet:

View File

@@ -4,7 +4,6 @@ import os
import subprocess import subprocess
import threading import threading
import time import time
from contextlib import contextmanager
from ..util import ( from ..util import (
collapse, collapse,
@@ -47,9 +46,9 @@ def lxc_start_cmd(args, out):
dns_ct = ix.get_dns_container() dns_ct = ix.get_dns_container()
dns_ct.ensure() dns_ct.ensure()
if not ix.find_dns_image(): 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() 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 names = args.names if args.names else RELAY_NAMES
relays = list(ix.get_container(n) for n in names) relays = list(ix.get_container(n) for n in names)
@@ -58,12 +57,12 @@ def lxc_start_cmd(args, out):
ct.ensure() ct.ensure()
ip = ct.ipv4 ip = ct.ipv4
print(" Configuring container hostname ...") out.print(" Configuring container hostname ...")
ct.configure_hosts(ip) 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) ct.write_ini(disable_ipv6=args.ipv4_only)
print(f" Config: {ct.ini}") out.print(f" Config: {ct.ini}")
if args.ipv4_only: if args.ipv4_only:
ct.disable_ipv6() ct.disable_ipv6()
ipv6 = None ipv6 = None
@@ -74,10 +73,10 @@ def lxc_start_cmd(args, out):
check=False, check=False,
) )
ipv6 = output.strip() if output else None 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}") out.green(f" Container {ct.name!r} ready: {ct.domain} -> {ip}")
print() out.print()
# Reset DNS zones only for the containers we just started # Reset DNS zones only for the containers we just started
started_cnames = {ct.name for ct in relays} 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] started = [c for c in managed if c["name"] in started_cnames]
if started: if started:
print( out.print(
f"Resetting DNS zones for {len(started)} domain(s) (A + AAAA records) ..." f"Resetting DNS zones for {len(started)}"
" domain(s) (A + AAAA records) ..."
) )
dns_ct.reset_dns_records(dns_ct.ipv4, started) dns_ct.reset_dns_records(dns_ct.ipv4, started)
for ct in relays: for ct in relays:
if ct.name in started_cnames: 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) 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 # Generate the unified SSH config
out.green("Writing ssh-config ...") out.green("Writing ssh-config ...")
ssh_cfg = ix.write_ssh_config() ssh_cfg = ix.write_ssh_config()
print(f" {ssh_cfg}") out.print(f" {ssh_cfg}")
# Verify SSH via the generated config # Verify SSH via the generated config
for ct in relays: 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): 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: else:
out.red(f" WARNING: SSH verification failed for {ct.name}") 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 # Optionally run cmdeploy run on each relay
if args.run: if args.run:
for ct in relays: for ct in relays:
with _section(out, f"cmdeploy run: {ct.sname} ({ct.domain})"): with out.section(f"cmdeploy run: {ct.sname} ({ct.domain})"):
ret = _run_cmdeploy("run", ct, ix, extra=["--skip-dns-check"]) ret = _run_cmdeploy("run", ct, ix, out, extra=["--skip-dns-check"])
if ret: if ret:
out.red(f"Deploy to {ct.sname} failed (exit {ret})") out.red(f"Deploy to {ct.sname} failed (exit {ret})")
return ret return ret
@@ -209,13 +215,13 @@ def lxc_test_cmd(args, out):
# Per-relay: start containers, then deploy in parallel. # Per-relay: start containers, then deploy in parallel.
ipv4_only_flags = {RELAY_NAMES[0]: False, RELAY_NAMES[1]: True} 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): for ct in map(ix.get_container, relay_names):
name = ct.sname name = ct.sname
ipv4_only = ipv4_only_flags.get(name, False) ipv4_only = ipv4_only_flags.get(name, False)
label = "IPv4-only" if ipv4_only else "dual-stack" 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.names = [name]
args.ipv4_only = ipv4_only args.ipv4_only = ipv4_only
args.run = False args.run = False
@@ -223,48 +229,45 @@ def lxc_test_cmd(args, out):
if ret: if ret:
return ret return ret
# Phase 2 deploy all relays in parallel # Phase 2: deploy all relays in parallel
to_deploy = [] to_deploy = []
for ct in map(ix.get_container, relay_names): for ct in map(ix.get_container, relay_names):
status = _deploy_status(ct, local_hash, ix) status = _deploy_status(ct, local_hash, ix)
if "IN-SYNC" in status: if "IN-SYNC" in status:
_section_line( out.section_line(f"cmdeploy run: {ct.sname}: {status}, skipping")
out, f"cmdeploy run: {ct.sname}{status}, skipping"
)
else: else:
to_deploy.append(ct) to_deploy.append(ct)
if to_deploy: if to_deploy:
with _section(out, "cmdeploy run (parallel)"): with out.section("cmdeploy run (parallel)"):
ret = _run_cmdeploy_parallel( ret = _run_cmdeploy_parallel(
"run", to_deploy, ix, out, extra=["--skip-dns-check"] "run", to_deploy, ix, out, extra=["--skip-dns-check"]
) )
if ret: if ret:
return ret return ret
# Phase 3 publish images (sequential, fast) # Phase 3: publish images (sequential, fast)
for ct in map(ix.get_container, relay_names): for ct in map(ix.get_container, relay_names):
if ct.publish_image(): if ct.publish_image():
_section_line(out, f"LXC: published {ct.sname} image") out.section_line(f"LXC: published {ct.sname} image")
else: else:
_section_line( out.section_line(
out, f"LXC: publish {ct.sname} image: skipped, cached",
f"LXC: publish {ct.sname} image — skipped, cached",
) )
for ct in map(ix.get_container, relay_names): for ct in map(ix.get_container, relay_names):
with _section(out, f"cmdeploy dns: {ct.sname} ({ct.domain})"): with out.section(f"cmdeploy dns: {ct.sname} ({ct.domain})"):
ret = _run_cmdeploy("dns", ct, ix, extra=["--zonefile", str(ct.zone)]) ret = _run_cmdeploy("dns", ct, ix, out, extra=["--zonefile", str(ct.zone)])
if ret: if ret:
out.red(f"DNS for {ct.sname} failed (exit {ret})") out.red(f"DNS for {ct.sname} failed (exit {ret})")
return ret return ret
with _section(out, "LXC: PowerDNS zone update"): with out.section("LXC: PowerDNS zone update"):
dns_ct = ix.get_dns_container() dns_ct = ix.get_dns_container()
for ct in map(ix.get_container, relay_names): for ct in map(ix.get_container, relay_names):
if ct.zone.exists(): if ct.zone.exists():
zone_data = ct.zone.read_text() 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) dns_ct.set_dns_records(zone_data)
# Run tests in both directions when two relays are available. # 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 = os.environ.copy()
env["CHATMAIL_DOMAIN2"] = second.domain env["CHATMAIL_DOMAIN2"] = second.domain
with _section(out, f"cmdeploy test: {label}"): with out.section(f"cmdeploy test: {label}"):
ret = _run_cmdeploy("test", first, ix, **({"env": env} if env else {})) ret = _run_cmdeploy("test", first, ix, out, **({"env": env} if env else {}))
if ret: if ret:
out.red(f"Tests failed (exit {ret})") out.red(f"Tests failed (exit {ret})")
return ret return ret
elapsed = time.time() - t_total 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 return 0
@@ -321,7 +330,7 @@ def lxc_status_cmd(args, out):
dns_ip = None dns_ip = None
for c in containers: 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: if c["name"] == ix.get_dns_container().name:
dns_ip = c["ip"] dns_ip = c["ip"]
@@ -330,7 +339,7 @@ def lxc_status_cmd(args, out):
return 0 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.""" """Print name/status, domain/IPs, and RAM for one container."""
cname = c["name"] cname = c["name"]
is_running = c.get("status") == "Running" is_running = c.get("status") == "Running"
@@ -343,13 +352,13 @@ def _print_container_status(c, ix, local_hash):
tag = "running" tag = "running"
else: else:
tag = f"running {_deploy_status(ct, local_hash, ix)}" 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 # Second line: domain, IPv4, IPv6
domain = c.get("domain", "") domain = c.get("domain", "")
ip = c.get("ip") or "?" ip = c.get("ip") or "?"
ipv6 = c.get("ipv6") 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 # Third line: RAM (RSS), config
indent = " " * 21 indent = " " * 21
@@ -365,20 +374,20 @@ def _print_container_status(c, ix, local_hash):
else: else:
detail = ram_str detail = ram_str
print(f" {indent}{detail}") out.print(f" {indent}{detail}")
print() out.print()
def _print_ssh_status(out, ix): def _print_ssh_status(out, ix):
"""Print SSH integration status.""" """Print SSH integration status."""
print() out.print()
ssh_cfg = ix.ssh_config_path ssh_cfg = ix.ssh_config_path
if ix.check_ssh_include(): if ix.check_ssh_include():
out.green("SSH: ~/.ssh/config includes lxconfigs/ssh-config ✓") out.green("SSH: ~/.ssh/config includes lxconfigs/ssh-config ✓")
else: else:
out.red("SSH: ~/.ssh/config does NOT include lxconfigs/ssh-config") out.red("SSH: ~/.ssh/config does NOT include lxconfigs/ssh-config")
print(" Add to ~/.ssh/config:") out.print(" Add to ~/.ssh/config:")
print(f" Include {ssh_cfg}") out.print(f" Include {ssh_cfg}")
def _print_dns_forwarding_status(out, dns_ip): 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}") out.green(f"DNS: .localchat forwarding to {dns_ip}")
elif dns_ok is False: elif dns_ok is False:
out.red("DNS: .localchat forwarding NOT configured") out.red("DNS: .localchat forwarding NOT configured")
print(" Run:") out.print(" Run:")
print(f" sudo resolvectl dns incusbr0 {dns_ip}") out.print(f" sudo resolvectl dns incusbr0 {dns_ip}")
print(" sudo resolvectl domain incusbr0 ~localchat") out.print(" sudo resolvectl domain incusbr0 ~localchat")
else: 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) 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): def _deploy_status(ct, local_hash, ix):
"""Return a human-readable deploy status string. """Return a human-readable deploy status string.
@@ -445,7 +434,7 @@ def _deploy_status(ct, local_hash, ix):
return "NOT DEPLOYED" return "NOT DEPLOYED"
# A container launched from the relay image has the same # 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() deployed_domain = ct.deployed_domain()
if deployed_domain and deployed_domain != ct.domain: if deployed_domain and deployed_domain != ct.domain:
return f"DOMAIN-MISMATCH (deployed: {deployed_domain})" return f"DOMAIN-MISMATCH (deployed: {deployed_domain})"
@@ -461,7 +450,7 @@ def _deploy_status(ct, local_hash, ix):
if deployed_hash != local_hash: if deployed_hash != local_hash:
return f"STALE (deployed: {short}, local: {local_short})" 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() local_version = get_version_string()
if deployed != local_version: if deployed != local_version:
return f"DIRTY ({local_short}, undeployed changes)" 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. """Run ``cmdeploy <subcmd>`` with standard --config/--ssh flags.
*ct* is a Container (uses ``ct.ini`` and ``ct.domain``). *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) cmd = _build_cmdeploy_cmd(subcmd, ct, ix, extra=extra)
if "cwd" not in kwargs: if "cwd" not in kwargs:
kwargs["cwd"] = str(ix.project_root) kwargs["cwd"] = str(ix.project_root)
print(f" [$ {cmd}]") out.print(f" [$ {cmd}]")
return shell(cmd, capture_output=False, **kwargs).returncode 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: for ct in containers:
cmd = _build_cmdeploy_cmd(subcmd, ct, ix, extra=extra) cmd = _build_cmdeploy_cmd(subcmd, ct, ix, extra=extra)
print(f" [{ct.sname}] $ {cmd}") out.print(f" [{ct.sname}] $ {cmd}")
proc = subprocess.Popen( proc = subprocess.Popen(
cmd, cmd,
shell=True, shell=True,
@@ -538,12 +527,14 @@ def _run_cmdeploy_parallel(subcmd, containers, ix, out, extra=None):
line = raw.rstrip("\n") line = raw.rstrip("\n")
lines.append(line) lines.append(line)
if "Starting operation" in line: if "Starting operation" in line:
print(f"{prefix} {line}") out.print(f"{prefix} {line}")
threads = [] threads = []
for ct, proc, lines in procs: for ct, proc, lines in procs:
t = threading.Thread( t = threading.Thread(
target=_reader, args=(ct, proc, lines), daemon=True, target=_reader,
args=(ct, proc, lines),
daemon=True,
) )
t.start() t.start()
threads.append(t) threads.append(t)
@@ -557,13 +548,10 @@ def _run_cmdeploy_parallel(subcmd, containers, ix, out, extra=None):
first_failure = 0 first_failure = 0
for ct, proc, lines in procs: for ct, proc, lines in procs:
if proc.returncode: if proc.returncode:
out.red( out.red(f"Deploy to {ct.sname} failed " f"(exit {proc.returncode})")
f"Deploy to {ct.sname} failed "
f"(exit {proc.returncode})"
)
tail = lines[-_FAIL_CONTEXT_LINES:] tail = lines[-_FAIL_CONTEXT_LINES:]
for tl in tail: for tl in tail:
print(f" [{ct.sname}] {tl}") out.print(f" [{ct.sname}] {tl}")
if not first_failure: if not first_failure:
first_failure = proc.returncode first_failure = proc.returncode

View File

@@ -72,7 +72,7 @@ class Incus:
""" """
containers = self.list_managed() containers = self.list_managed()
key_path = self.ssh_key_path 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: for c in containers:
hosts = [c["name"]] hosts = [c["name"]]
domain = c.get("domain", "") domain = c.get("domain", "")
@@ -533,6 +533,19 @@ class RelayContainer(Container):
systemctl restart unbound 2>/dev/null || true 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): def write_ini(self, disable_ipv6=False):
"""Generate a chatmail.ini config file in lxconfigs/.""" """Generate a chatmail.ini config file in lxconfigs/."""
from chatmaild.config import write_initial_config from chatmaild.config import write_initial_config

View File

@@ -126,7 +126,9 @@ class TestLxcStatus:
assert "status" in result.stdout.lower() assert "status" in result.stdout.lower()
def test_shows_containers(self, lxc_setup, capsys): def test_shows_containers(self, lxc_setup, capsys):
class QuietOut: from cmdeploy.cmdeploy import Out
class QuietOut(Out):
def red(self, msg, **kw): def red(self, msg, **kw):
pass pass

View File

@@ -61,6 +61,16 @@ def test_git_helpers_with_commits_and_diffs(tmp_path):
assert new_hash != git_hash assert new_hash != git_hash
assert get_version_string(root=tmp_path) == new_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): def test_build_chatmaild_sdist(tmp_path):
dist_dir = tmp_path / "dist" dist_dir = tmp_path / "dist"
@@ -70,7 +80,7 @@ def test_build_chatmaild_sdist(tmp_path):
assert result.name.endswith(".tar.gz") assert result.name.endswith(".tar.gz")
assert result.stat().st_size > 0 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 mtime = result.stat().st_mtime
result2 = build_chatmaild_sdist(dist_dir) result2 = build_chatmaild_sdist(dist_dir)
assert result2 == result assert result2 == result

View File

@@ -17,11 +17,11 @@ def collapse(text):
Handy for writing shell commands across multiple lines:: Handy for writing shell commands across multiple lines::
cmd = collapse(f\"\"\" cmd = collapse(f\"""
cmdeploy run cmdeploy run
--config {ct.ini} --config {ct.ini}
--ssh-host {ct.domain} --ssh-host {ct.domain}
\"\"\") \""")
""" """
return textwrap.dedent(text).replace("\n", " ").strip() return textwrap.dedent(text).replace("\n", " ").strip()
@@ -52,18 +52,34 @@ def get_git_hash(root=None):
return 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): def get_version_string(root=None):
"""Return ``git_hash\\ngit_diff`` for the local working tree. """Return ``git_hash\\ngit_diff`` for the local working tree.
Used by :class:`~cmdeploy.deployers.GithashDeployer` to write Used by :class:`~cmdeploy.deployers.GithashDeployer` to write
``/etc/chatmail-version`` and by ``lxc-status`` to compare ``/etc/chatmail-version`` and by ``lxc-status`` to compare
the deployed state against the local checkout. 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: if root is None:
root = _project_root() root = _project_root()
git_hash = get_git_hash(root=root) or "unknown" git_hash = get_git_hash(root=root) or "unknown"
excludes = " ".join(f"'{e}'" for e in DIFF_EXCLUDES)
try: 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: except Exception:
git_diff = "" git_diff = ""
if git_diff: if git_diff: