Skip to content

Commit bfc7f0f

Browse files
committed
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.
1 parent ed9b409 commit bfc7f0f

23 files changed

Lines changed: 2102 additions & 123 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ __pycache__/
55
*.swp
66
*qr-*.png
77
chatmail*.ini
8+
lxconfigs/
89

910

1011
# C extensions

cmdeploy/src/cmdeploy/chatmail.zone.j2

Lines changed: 0 additions & 32 deletions
This file was deleted.

cmdeploy/src/cmdeploy/cmdeploy.py

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,23 @@
1818
from termcolor import colored
1919

2020
from . import dns, remote
21-
from .sshexec import LocalExec, SSHExec
21+
from .lxc.cli import ( # noqa: F401
22+
lxc_start_cmd,
23+
lxc_start_cmd_options,
24+
lxc_status_cmd,
25+
lxc_status_cmd_options,
26+
lxc_stop_cmd,
27+
lxc_stop_cmd_options,
28+
lxc_test_cmd,
29+
lxc_test_cmd_options,
30+
)
31+
from .sshexec import (
32+
LocalExec,
33+
SSHExec,
34+
resolve_host_from_ssh_config,
35+
resolve_key_from_ssh_config,
36+
)
37+
from .www import main as webdev_main
2238

2339
#
2440
# cmdeploy sub commands and options
@@ -82,13 +98,16 @@ def run_cmd_options(parser):
8298
help="disable checks nslookup for dns",
8399
)
84100
add_ssh_host_option(parser)
101+
add_ssh_config_option(parser)
102+
103+
85104

86105

87106
def run_cmd(args, out):
88107
"""Deploy chatmail services on the remote server."""
89108

90109
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
91-
sshexec = get_sshexec(ssh_host)
110+
sshexec = get_sshexec(ssh_host, ssh_config=args.ssh_config)
92111
require_iroh = args.config.enable_iroh_relay
93112
strict_tls = args.config.tls_cert_mode == "acme"
94113
if not args.dns_check_disabled:
@@ -108,6 +127,18 @@ def run_cmd(args, out):
108127
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
109128

110129
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
130+
ssh_config = args.ssh_config
131+
if ssh_config:
132+
ssh_config = str(Path(ssh_config).resolve())
133+
134+
# Use pyinfra's native SSH data keys to configure the connection directly
135+
# rather than relying on paramiko config parsing (see also sshexec.py)
136+
ip = resolve_host_from_ssh_config(ssh_host, ssh_config)
137+
key = resolve_key_from_ssh_config(ssh_host, ssh_config)
138+
data_args = f"--data ssh_hostname={ip} --data ssh_known_hosts_file=/dev/null"
139+
if key:
140+
data_args += f" --data ssh_key={key}"
141+
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y {data_args}"
111142
if ssh_host in ["localhost", "@docker"]:
112143
if ssh_host == "@docker":
113144
env["CHATMAIL_NOPORTCHECK"] = "True"
@@ -139,15 +170,16 @@ def dns_cmd_options(parser):
139170
dest="zonefile",
140171
type=pathlib.Path,
141172
default=None,
142-
help="write out a zonefile",
173+
help="write DNS records in standard BIND format to the given file",
143174
)
144175
add_ssh_host_option(parser)
176+
add_ssh_config_option(parser)
145177

146178

147179
def dns_cmd(args, out):
148180
"""Check DNS entries and optionally generate dns zone file."""
149181
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
150-
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
182+
sshexec = get_sshexec(ssh_host, verbose=args.verbose, ssh_config=args.ssh_config)
151183
tls_cert_mode = args.config.tls_cert_mode
152184
strict_tls = tls_cert_mode == "acme"
153185
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
@@ -178,13 +210,14 @@ def dns_cmd(args, out):
178210

179211
def status_cmd_options(parser):
180212
add_ssh_host_option(parser)
213+
add_ssh_config_option(parser)
181214

182215

183216
def status_cmd(args, out):
184217
"""Display status for online chatmail instance."""
185218

186219
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
187-
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
220+
sshexec = get_sshexec(ssh_host, verbose=args.verbose, ssh_config=args.ssh_config)
188221

189222
out.green(f"chatmail domain: {args.config.mail_domain}")
190223
if args.config.privacy_mail:
@@ -204,14 +237,18 @@ def test_cmd_options(parser):
204237
help="also run slow tests",
205238
)
206239
add_ssh_host_option(parser)
240+
add_ssh_config_option(parser)
207241

208242

209243
def test_cmd(args, out):
210244
"""Run local and online tests for chatmail deployment."""
211245

212246
env = os.environ.copy()
247+
env["CHATMAIL_INI"] = str(args.inipath.resolve())
213248
if args.ssh_host:
214249
env["CHATMAIL_SSH"] = args.ssh_host
250+
if args.ssh_config:
251+
env["CHATMAIL_SSH_CONFIG"] = str(Path(args.ssh_config).resolve())
215252

216253
pytest_path = shutil.which("pytest")
217254
pytest_args = [
@@ -276,9 +313,7 @@ def bench_cmd(args, out):
276313

277314
def webdev_cmd(args, out):
278315
"""Run local web development loop for static web pages."""
279-
from .www import main
280-
281-
main()
316+
webdev_main()
282317

283318

284319
#
@@ -321,6 +356,16 @@ def add_ssh_host_option(parser):
321356
)
322357

323358

359+
def add_ssh_config_option(parser):
360+
parser.add_argument(
361+
"--ssh-config",
362+
dest="ssh_config",
363+
type=Path,
364+
default=None,
365+
help="Path to an SSH config file (e.g. lxconfigs/ssh-config).",
366+
)
367+
368+
324369
def add_config_option(parser):
325370
parser.add_argument(
326371
"--config",
@@ -330,6 +375,7 @@ def add_config_option(parser):
330375
type=Path,
331376
help="path to the chatmail.ini file",
332377
)
378+
333379
parser.add_argument(
334380
"--verbose",
335381
"-v",
@@ -340,15 +386,16 @@ def add_config_option(parser):
340386
)
341387

342388

343-
def add_subcommand(subparsers, func):
389+
def add_subcommand(subparsers, func, add_config=True):
344390
name = func.__name__
345391
assert name.endswith("_cmd")
346-
name = name[:-4]
392+
name = name[:-4].replace("_", "-")
347393
doc = func.__doc__.strip()
348394
help = doc.split("\n")[0].strip(".")
349395
p = subparsers.add_parser(name, description=doc, help=help)
350396
p.set_defaults(func=func)
351-
add_config_option(p)
397+
if add_config:
398+
add_config_option(p)
352399
return p
353400

354401

@@ -362,39 +409,42 @@ def get_parser():
362409
"""Return an ArgumentParser for the 'cmdeploy' CLI"""
363410

364411
parser = argparse.ArgumentParser(description=description.strip())
412+
parser.set_defaults(func=None, inipath=None)
365413
subparsers = parser.add_subparsers(title="subcommands")
366414

367415
# find all subcommands in the module namespace
368416
glob = globals()
369417
for name, func in glob.items():
370418
if name.endswith("_cmd"):
371-
subparser = add_subcommand(subparsers, func)
419+
needs_config = not name.startswith("lxc_")
420+
subparser = add_subcommand(subparsers, func, add_config=needs_config)
372421
addopts = glob.get(name + "_options")
373422
if addopts is not None:
374423
addopts(subparser)
375424

376425
return parser
377426

378427

379-
def get_sshexec(ssh_host: str, verbose=True):
428+
def get_sshexec(ssh_host: str, verbose=True, ssh_config=None):
380429
if ssh_host in ["localhost", "@local"]:
381430
return LocalExec(verbose, docker=False)
382431
elif ssh_host == "@docker":
383432
return LocalExec(verbose, docker=True)
384433
if verbose:
385434
print(f"[ssh] login to {ssh_host}")
386-
return SSHExec(ssh_host, verbose=verbose)
435+
return SSHExec(ssh_host, verbose=verbose, ssh_config=ssh_config)
387436

388437

389438
def main(args=None):
390439
"""Provide main entry point for 'cmdeploy' CLI invocation."""
391440
parser = get_parser()
392441
args = parser.parse_args(args=args)
393-
if not hasattr(args, "func"):
442+
if args.func is None:
394443
return parser.parse_args(["-h"])
395444

396445
out = Out()
397446
kwargs = {}
447+
398448
if args.func.__name__ not in ("init_cmd", "fmt_cmd"):
399449
if not args.inipath.exists():
400450
out.red(f"expecting {args.inipath} to exist, run init first?")

cmdeploy/src/cmdeploy/deployers.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from pyinfra.operations import apt, files, pip, server, systemd
1919

2020
from cmdeploy.cmdeploy import Out
21+
from cmdeploy.util import get_version_string
2122

2223
from .acmetool import AcmetoolDeployer
2324
from .basedeploy import (
@@ -271,8 +272,14 @@ def configure(self):
271272
logger.warning("Web page build failed, skipping website deployment")
272273
return
273274
# if it is not a hugo page, upload it as is
274-
files.rsync(
275-
f"{www_path}/", "/var/www/html", flags=["-avz", "--chown=www-data"]
275+
# pyinfra files.rsync (experimental) causes problems with ssh-config configuration
276+
# the stable files.sync should do
277+
files.sync(
278+
src=str(www_path),
279+
dest="/var/www/html",
280+
user="www-data",
281+
group="www-data",
282+
delete=True,
276283
)
277284

278285

@@ -524,22 +531,15 @@ def activate(self):
524531

525532
class GithashDeployer(Deployer):
526533
def activate(self):
527-
try:
528-
git_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode()
529-
except Exception:
530-
git_hash = "unknown\n"
531-
try:
532-
git_diff = subprocess.check_output(["git", "diff"]).decode()
533-
except Exception:
534-
git_diff = ""
535534
files.put(
536535
name="Upload chatmail relay git commit hash",
537-
src=StringIO(git_hash + git_diff),
536+
src=StringIO(get_version_string()),
538537
dest="/etc/chatmail-version",
539538
mode="700",
540539
)
541540

542541

542+
543543
def get_tls_deployer(config, mail_domain):
544544
"""Select the appropriate TLS deployer based on config."""
545545
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
@@ -578,11 +578,17 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
578578
)
579579

580580
# Check if mtail_address interface is available (if configured)
581-
if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'):
581+
if config.mtail_address and config.mtail_address not in (
582+
"127.0.0.1",
583+
"::1",
584+
"localhost",
585+
):
582586
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
583587
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
584588
if config.mtail_address not in all_addresses:
585-
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
589+
Out().red(
590+
f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n"
591+
)
586592
exit(1)
587593

588594
if not os.environ.get("CHATMAIL_NOPORTCHECK"):

0 commit comments

Comments
 (0)