mirror of
https://github.com/chatmail/relay.git
synced 2026-05-20 12:58:04 +00:00
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:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user