Skip to content

Commit 8aefd4e

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 8aefd4e

24 files changed

Lines changed: 2161 additions & 143 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: 72 additions & 18 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,18 +98,21 @@ 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)
85102

86103

87104
def run_cmd(args, out):
88105
"""Deploy chatmail services on the remote server."""
89106

90107
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
91-
sshexec = get_sshexec(ssh_host)
108+
sshexec = get_sshexec(ssh_host, ssh_config=args.ssh_config)
92109
require_iroh = args.config.enable_iroh_relay
93110
strict_tls = args.config.tls_cert_mode == "acme"
94111
if not args.dns_check_disabled:
95112
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
96-
if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red):
113+
if not dns.check_initial_remote_data(
114+
remote_data, strict_tls=strict_tls, print=out.red
115+
):
97116
return 1
98117

99118
env = os.environ.copy()
@@ -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"
@@ -122,7 +153,11 @@ def run_cmd(args, out):
122153
out.check_call(cmd, env=env)
123154
if args.website_only:
124155
out.green("Website deployment completed.")
125-
elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]:
156+
elif (
157+
not args.dns_check_disabled
158+
and strict_tls
159+
and not remote_data["acme_account_url"]
160+
):
126161
out.red("Deploy completed but letsencrypt not configured")
127162
out.red("Run 'cmdeploy run' again")
128163
else:
@@ -139,15 +174,16 @@ def dns_cmd_options(parser):
139174
dest="zonefile",
140175
type=pathlib.Path,
141176
default=None,
142-
help="write out a zonefile",
177+
help="write DNS records in standard BIND format to the given file",
143178
)
144179
add_ssh_host_option(parser)
180+
add_ssh_config_option(parser)
145181

146182

147183
def dns_cmd(args, out):
148184
"""Check DNS entries and optionally generate dns zone file."""
149185
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
150-
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
186+
sshexec = get_sshexec(ssh_host, verbose=args.verbose, ssh_config=args.ssh_config)
151187
tls_cert_mode = args.config.tls_cert_mode
152188
strict_tls = tls_cert_mode == "acme"
153189
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
@@ -178,13 +214,14 @@ def dns_cmd(args, out):
178214

179215
def status_cmd_options(parser):
180216
add_ssh_host_option(parser)
217+
add_ssh_config_option(parser)
181218

182219

183220
def status_cmd(args, out):
184221
"""Display status for online chatmail instance."""
185222

186223
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
187-
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
224+
sshexec = get_sshexec(ssh_host, verbose=args.verbose, ssh_config=args.ssh_config)
188225

189226
out.green(f"chatmail domain: {args.config.mail_domain}")
190227
if args.config.privacy_mail:
@@ -204,14 +241,18 @@ def test_cmd_options(parser):
204241
help="also run slow tests",
205242
)
206243
add_ssh_host_option(parser)
244+
add_ssh_config_option(parser)
207245

208246

209247
def test_cmd(args, out):
210248
"""Run local and online tests for chatmail deployment."""
211249

212250
env = os.environ.copy()
251+
env["CHATMAIL_INI"] = str(args.inipath.resolve())
213252
if args.ssh_host:
214253
env["CHATMAIL_SSH"] = args.ssh_host
254+
if args.ssh_config:
255+
env["CHATMAIL_SSH_CONFIG"] = str(Path(args.ssh_config).resolve())
215256

216257
pytest_path = shutil.which("pytest")
217258
pytest_args = [
@@ -276,9 +317,7 @@ def bench_cmd(args, out):
276317

277318
def webdev_cmd(args, out):
278319
"""Run local web development loop for static web pages."""
279-
from .www import main
280-
281-
main()
320+
webdev_main()
282321

283322

284323
#
@@ -321,6 +360,16 @@ def add_ssh_host_option(parser):
321360
)
322361

323362

363+
def add_ssh_config_option(parser):
364+
parser.add_argument(
365+
"--ssh-config",
366+
dest="ssh_config",
367+
type=Path,
368+
default=None,
369+
help="Path to an SSH config file (e.g. lxconfigs/ssh-config).",
370+
)
371+
372+
324373
def add_config_option(parser):
325374
parser.add_argument(
326375
"--config",
@@ -330,6 +379,7 @@ def add_config_option(parser):
330379
type=Path,
331380
help="path to the chatmail.ini file",
332381
)
382+
333383
parser.add_argument(
334384
"--verbose",
335385
"-v",
@@ -340,15 +390,16 @@ def add_config_option(parser):
340390
)
341391

342392

343-
def add_subcommand(subparsers, func):
393+
def add_subcommand(subparsers, func, add_config=True):
344394
name = func.__name__
345395
assert name.endswith("_cmd")
346-
name = name[:-4]
396+
name = name[:-4].replace("_", "-")
347397
doc = func.__doc__.strip()
348398
help = doc.split("\n")[0].strip(".")
349399
p = subparsers.add_parser(name, description=doc, help=help)
350400
p.set_defaults(func=func)
351-
add_config_option(p)
401+
if add_config:
402+
add_config_option(p)
352403
return p
353404

354405

@@ -362,40 +413,43 @@ def get_parser():
362413
"""Return an ArgumentParser for the 'cmdeploy' CLI"""
363414

364415
parser = argparse.ArgumentParser(description=description.strip())
416+
parser.set_defaults(func=None, inipath=None)
365417
subparsers = parser.add_subparsers(title="subcommands")
366418

367419
# find all subcommands in the module namespace
368420
glob = globals()
369421
for name, func in glob.items():
370422
if name.endswith("_cmd"):
371-
subparser = add_subcommand(subparsers, func)
423+
needs_config = not name.startswith("lxc_")
424+
subparser = add_subcommand(subparsers, func, add_config=needs_config)
372425
addopts = glob.get(name + "_options")
373426
if addopts is not None:
374427
addopts(subparser)
375428

376429
return parser
377430

378431

379-
def get_sshexec(ssh_host: str, verbose=True):
432+
def get_sshexec(ssh_host: str, verbose=True, ssh_config=None):
380433
if ssh_host in ["localhost", "@local"]:
381434
return LocalExec(verbose, docker=False)
382435
elif ssh_host == "@docker":
383436
return LocalExec(verbose, docker=True)
384437
if verbose:
385438
print(f"[ssh] login to {ssh_host}")
386-
return SSHExec(ssh_host, verbose=verbose)
439+
return SSHExec(ssh_host, verbose=verbose, ssh_config=ssh_config)
387440

388441

389442
def main(args=None):
390443
"""Provide main entry point for 'cmdeploy' CLI invocation."""
391444
parser = get_parser()
392445
args = parser.parse_args(args=args)
393-
if not hasattr(args, "func"):
446+
if args.func is None:
394447
return parser.parse_args(["-h"])
395448

396449
out = Out()
397450
kwargs = {}
398-
if args.func.__name__ not in ("init_cmd", "fmt_cmd"):
451+
452+
if args.inipath is not None and args.func.__name__ not in ("init_cmd", "fmt_cmd"):
399453
if not args.inipath.exists():
400454
out.red(f"expecting {args.inipath} to exist, run init first?")
401455
raise SystemExit(1)

cmdeploy/src/cmdeploy/deployers.py

Lines changed: 18 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,17 +531,9 @@ 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
)
@@ -578,11 +577,17 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
578577
)
579578

580579
# 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'):
580+
if config.mtail_address and config.mtail_address not in (
581+
"127.0.0.1",
582+
"::1",
583+
"localhost",
584+
):
582585
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
583586
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
584587
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")
588+
Out().red(
589+
f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n"
590+
)
586591
exit(1)
587592

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

0 commit comments

Comments
 (0)