1818from termcolor import colored
1919
2020from . 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
87104def 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
147183def 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
179215def status_cmd_options (parser ):
180216 add_ssh_host_option (parser )
217+ add_ssh_config_option (parser )
181218
182219
183220def 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
209247def 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
277318def 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+
324373def 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
389442def 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 )
0 commit comments