mtr-cli 0.2.0__py3-none-any.whl → 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mtr/__init__.py +3 -0
- mtr/cli.py +153 -110
- mtr/config.py +14 -0
- mtr/ssh.py +99 -166
- mtr/sync.py +129 -237
- mtr/updater.py +130 -0
- {mtr_cli-0.2.0.dist-info → mtr_cli-2.0.0.dist-info}/METADATA +37 -25
- mtr_cli-2.0.0.dist-info/RECORD +12 -0
- mtr_cli-0.2.0.dist-info/RECORD +0 -11
- {mtr_cli-0.2.0.dist-info → mtr_cli-2.0.0.dist-info}/WHEEL +0 -0
- {mtr_cli-0.2.0.dist-info → mtr_cli-2.0.0.dist-info}/entry_points.txt +0 -0
- {mtr_cli-0.2.0.dist-info → mtr_cli-2.0.0.dist-info}/licenses/LICENSE +0 -0
mtr/__init__.py
CHANGED
mtr/cli.py
CHANGED
|
@@ -4,17 +4,22 @@ from datetime import datetime
|
|
|
4
4
|
|
|
5
5
|
import click
|
|
6
6
|
|
|
7
|
+
from mtr import __version__
|
|
7
8
|
from mtr.config import ConfigError, ConfigLoader
|
|
8
9
|
from mtr.logger import LogLevel, get_logger, setup_logging
|
|
9
|
-
from mtr.ssh import
|
|
10
|
-
from mtr.sync import RsyncSyncer,
|
|
10
|
+
from mtr.ssh import SSHError, run_ssh_command
|
|
11
|
+
from mtr.sync import RsyncSyncer, SyncError
|
|
12
|
+
from mtr.updater import UpdateChecker
|
|
11
13
|
|
|
12
14
|
DEFAULT_CONFIG_TEMPLATE = """# MTRemote Configuration
|
|
13
15
|
defaults:
|
|
14
|
-
# 默认同步引擎
|
|
15
|
-
# 选项: "rsync" (推荐), "sftp"
|
|
16
|
+
# 默认同步引擎 (仅支持 rsync)
|
|
16
17
|
sync: "rsync"
|
|
17
18
|
|
|
19
|
+
# 是否尊重 .gitignore 文件
|
|
20
|
+
# 设置为 true 时,rsync 会自动读取项目根目录的 .gitignore 并排除匹配的文件
|
|
21
|
+
respect_gitignore: true
|
|
22
|
+
|
|
18
23
|
exclude:
|
|
19
24
|
- ".git/"
|
|
20
25
|
- "__pycache__/"
|
|
@@ -34,12 +39,9 @@ servers:
|
|
|
34
39
|
# 预设命令 (可选)
|
|
35
40
|
# pre_cmd: "source ~/.bashrc && conda activate myenv"
|
|
36
41
|
|
|
37
|
-
# 密码认证 (
|
|
42
|
+
# 密码认证 (可选,需要安装 sshpass)
|
|
38
43
|
# password: "secret"
|
|
39
44
|
|
|
40
|
-
# 强制同步引擎 (可选)
|
|
41
|
-
# sync: "sftp"
|
|
42
|
-
|
|
43
45
|
# 该服务器的下载位置(可选,覆盖 defaults)
|
|
44
46
|
# download_dir: "./backups/dev-node"
|
|
45
47
|
"""
|
|
@@ -66,6 +68,7 @@ def _init_config():
|
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
@click.command(context_settings=dict(ignore_unknown_options=True, allow_extra_args=True))
|
|
71
|
+
@click.version_option(version=__version__, prog_name="mtr-cli", help="Show version and exit.")
|
|
69
72
|
@click.option("-s", "--server", help="Target server alias")
|
|
70
73
|
@click.option("--sync/--no-sync", default=True, help="Enable/Disable code sync")
|
|
71
74
|
@click.option("--dry-run", is_flag=True, help="Print commands without executing")
|
|
@@ -76,10 +79,36 @@ def _init_config():
|
|
|
76
79
|
@click.option("--log-file", help="Path to log file (default: ./.mtr/logs/mtr_YYYYMMDD_HHMMSS.log)")
|
|
77
80
|
@click.option("--get", "remote_get_path", help="Remote path to download from")
|
|
78
81
|
@click.option("--to", "local_dest_path", help="Local destination path for download (optional)")
|
|
82
|
+
@click.option("--no-check-update", is_flag=True, help="Disable update check")
|
|
79
83
|
@click.argument("command", nargs=-1, type=click.UNPROCESSED)
|
|
80
|
-
def cli(
|
|
84
|
+
def cli(
|
|
85
|
+
server,
|
|
86
|
+
sync,
|
|
87
|
+
dry_run,
|
|
88
|
+
tty,
|
|
89
|
+
init,
|
|
90
|
+
enable_log,
|
|
91
|
+
log_level,
|
|
92
|
+
log_file,
|
|
93
|
+
remote_get_path,
|
|
94
|
+
local_dest_path,
|
|
95
|
+
no_check_update,
|
|
96
|
+
command,
|
|
97
|
+
):
|
|
81
98
|
"""MTRemote: Sync and Execute code on remote server."""
|
|
82
99
|
|
|
100
|
+
# Check for updates (async, non-blocking)
|
|
101
|
+
update_message = None
|
|
102
|
+
if not no_check_update and not init:
|
|
103
|
+
checker = UpdateChecker()
|
|
104
|
+
# Try to get cached update message first (from previous check)
|
|
105
|
+
update_message = checker.get_cached_update_message()
|
|
106
|
+
# Trigger background check for next time
|
|
107
|
+
try:
|
|
108
|
+
checker.check()
|
|
109
|
+
except Exception:
|
|
110
|
+
pass # Silently fail update check
|
|
111
|
+
|
|
83
112
|
# Get logger instance (will be no-op if not setup)
|
|
84
113
|
logger = get_logger()
|
|
85
114
|
|
|
@@ -186,55 +215,67 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
186
215
|
# Resolve exclude
|
|
187
216
|
exclude = config.global_defaults.get("exclude", []) + server_conf.get("exclude", [])
|
|
188
217
|
|
|
218
|
+
# Get respect_gitignore setting
|
|
219
|
+
respect_gitignore = config.get_respect_gitignore()
|
|
220
|
+
|
|
189
221
|
# Determine engine
|
|
190
222
|
engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
|
|
191
223
|
|
|
192
|
-
if
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
remote_dir=remote_dir,
|
|
196
|
-
host=host,
|
|
197
|
-
user=user,
|
|
198
|
-
key_filename=key_filename,
|
|
199
|
-
password=password,
|
|
200
|
-
port=port,
|
|
201
|
-
exclude=exclude,
|
|
202
|
-
)
|
|
203
|
-
elif engine == "sftp":
|
|
204
|
-
syncer = SftpSyncer(
|
|
205
|
-
local_dir=local_dir,
|
|
206
|
-
remote_dir=remote_dir,
|
|
207
|
-
host=host,
|
|
208
|
-
user=user,
|
|
209
|
-
key_filename=key_filename,
|
|
210
|
-
password=password,
|
|
211
|
-
port=port,
|
|
212
|
-
exclude=exclude,
|
|
213
|
-
)
|
|
214
|
-
else:
|
|
224
|
+
# Check if SFTP is configured
|
|
225
|
+
if engine == "sftp":
|
|
226
|
+
logger.error("SFTP mode is no longer supported. Please use rsync.", module="mtr.cli")
|
|
215
227
|
click.secho(
|
|
216
|
-
|
|
217
|
-
fg="
|
|
228
|
+
"Error: SFTP mode has been removed. Please update your config to use 'sync: rsync'.",
|
|
229
|
+
fg="red",
|
|
230
|
+
err=True,
|
|
218
231
|
)
|
|
219
|
-
|
|
232
|
+
sys.exit(1)
|
|
233
|
+
|
|
234
|
+
syncer = RsyncSyncer(
|
|
235
|
+
local_dir=local_dir,
|
|
236
|
+
remote_dir=remote_dir,
|
|
237
|
+
host=host,
|
|
238
|
+
user=user,
|
|
239
|
+
key_filename=key_filename,
|
|
240
|
+
password=password,
|
|
241
|
+
port=port,
|
|
242
|
+
exclude=exclude,
|
|
243
|
+
respect_gitignore=respect_gitignore,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
if dry_run:
|
|
248
|
+
click.echo(f"[DryRun] Would sync {local_dir} -> {remote_dir}")
|
|
249
|
+
logger.info(f"[DryRun] Would sync {local_dir} -> {remote_dir}", module="mtr.sync")
|
|
250
|
+
else:
|
|
251
|
+
if is_interactive and console:
|
|
252
|
+
# TTY mode: single line real-time update using Rich Live
|
|
253
|
+
from rich.live import Live
|
|
254
|
+
from rich.text import Text
|
|
255
|
+
|
|
256
|
+
with Live(Text("Starting sync...", style="blue"), refresh_per_second=10) as live:
|
|
220
257
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
258
|
+
def show_sync_progress(filename):
|
|
259
|
+
# Get relative path for cleaner display
|
|
260
|
+
rel_path = os.path.relpath(filename, local_dir)
|
|
261
|
+
live.update(Text(f"Syncing: {rel_path}", style="blue"))
|
|
262
|
+
|
|
263
|
+
syncer.sync(show_progress=True, progress_callback=show_sync_progress)
|
|
264
|
+
live.update(Text("Sync completed!", style="green"))
|
|
226
265
|
else:
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
logger.
|
|
236
|
-
|
|
237
|
-
|
|
266
|
+
# no_tty mode: print each file on new line
|
|
267
|
+
def show_sync_progress(filename):
|
|
268
|
+
rel_path = os.path.relpath(filename, local_dir)
|
|
269
|
+
click.echo(f"Syncing: {rel_path}")
|
|
270
|
+
|
|
271
|
+
click.secho("Syncing code...", fg="blue")
|
|
272
|
+
syncer.sync(show_progress=True, progress_callback=show_sync_progress)
|
|
273
|
+
click.secho("Sync completed!", fg="green")
|
|
274
|
+
logger.info(f"Sync completed: {local_dir} -> {remote_dir}", module="mtr.sync")
|
|
275
|
+
except SyncError as e:
|
|
276
|
+
logger.error(f"Sync failed: {e}", module="mtr.sync")
|
|
277
|
+
click.secho(f"Sync Failed: {e}", fg="red", err=True)
|
|
278
|
+
sys.exit(1)
|
|
238
279
|
|
|
239
280
|
# 3. Download from remote (if --get is specified)
|
|
240
281
|
if remote_get_path:
|
|
@@ -260,50 +301,61 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
260
301
|
# Resolve exclude
|
|
261
302
|
exclude = config.global_defaults.get("exclude", []) + server_conf.get("exclude", [])
|
|
262
303
|
|
|
304
|
+
# Get respect_gitignore setting
|
|
305
|
+
respect_gitignore = config.get_respect_gitignore()
|
|
306
|
+
|
|
263
307
|
# Determine engine
|
|
264
308
|
engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
|
|
265
309
|
|
|
266
|
-
if
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
remote_dir=".", # Not used for download
|
|
270
|
-
host=host,
|
|
271
|
-
user=user,
|
|
272
|
-
key_filename=key_filename,
|
|
273
|
-
password=password,
|
|
274
|
-
port=port,
|
|
275
|
-
exclude=exclude,
|
|
276
|
-
)
|
|
277
|
-
elif engine == "sftp":
|
|
278
|
-
syncer = SftpSyncer(
|
|
279
|
-
local_dir=".", # Not used for download
|
|
280
|
-
remote_dir=".", # Not used for download
|
|
281
|
-
host=host,
|
|
282
|
-
user=user,
|
|
283
|
-
key_filename=key_filename,
|
|
284
|
-
password=password,
|
|
285
|
-
port=port,
|
|
286
|
-
exclude=exclude,
|
|
287
|
-
)
|
|
288
|
-
else:
|
|
310
|
+
# Check if SFTP is configured
|
|
311
|
+
if engine == "sftp":
|
|
312
|
+
logger.error("SFTP mode is no longer supported. Please use rsync.", module="mtr.cli")
|
|
289
313
|
click.secho(
|
|
290
|
-
|
|
291
|
-
fg="
|
|
314
|
+
"Error: SFTP mode has been removed. Please update your config to use 'sync: rsync'.",
|
|
315
|
+
fg="red",
|
|
316
|
+
err=True,
|
|
292
317
|
)
|
|
293
318
|
sys.exit(1)
|
|
294
319
|
|
|
320
|
+
syncer = RsyncSyncer(
|
|
321
|
+
local_dir=".", # Not used for download
|
|
322
|
+
remote_dir=".", # Not used for download
|
|
323
|
+
host=host,
|
|
324
|
+
user=user,
|
|
325
|
+
key_filename=key_filename,
|
|
326
|
+
password=password,
|
|
327
|
+
port=port,
|
|
328
|
+
exclude=exclude,
|
|
329
|
+
respect_gitignore=respect_gitignore,
|
|
330
|
+
)
|
|
331
|
+
|
|
295
332
|
try:
|
|
296
333
|
if dry_run:
|
|
297
334
|
click.echo(f"[DryRun] Would download {remote_get_path} -> {local_dest}")
|
|
298
335
|
logger.info(f"[DryRun] Would download {remote_get_path} -> {local_dest}", module="mtr.sync")
|
|
299
336
|
else:
|
|
300
337
|
if is_interactive and console:
|
|
301
|
-
|
|
302
|
-
|
|
338
|
+
# TTY mode: single line real-time update using Rich Live
|
|
339
|
+
from rich.live import Live
|
|
340
|
+
from rich.text import Text
|
|
341
|
+
|
|
342
|
+
with Live(Text("Starting download...", style="blue"), refresh_per_second=10) as live:
|
|
343
|
+
|
|
344
|
+
def show_download_progress(filename):
|
|
345
|
+
live.update(Text(f"Downloading: {filename}", style="blue"))
|
|
346
|
+
|
|
347
|
+
syncer.download(
|
|
348
|
+
remote_get_path, local_dest, show_progress=True, progress_callback=show_download_progress
|
|
349
|
+
)
|
|
350
|
+
live.update(Text("Download completed!", style="green"))
|
|
303
351
|
console.print(f"✅ [green]Downloaded:[/green] {remote_get_path} -> {local_dest}")
|
|
304
352
|
else:
|
|
353
|
+
# no_tty mode: print each file on new line
|
|
354
|
+
def show_download_progress(filename):
|
|
355
|
+
click.echo(f"Downloading: {filename}")
|
|
356
|
+
|
|
305
357
|
click.secho(f"Downloading {remote_get_path}...", fg="blue")
|
|
306
|
-
syncer.download(remote_get_path, local_dest)
|
|
358
|
+
syncer.download(remote_get_path, local_dest, show_progress=True, progress_callback=show_download_progress)
|
|
307
359
|
click.secho(f"Download completed: {local_dest}", fg="green")
|
|
308
360
|
logger.info(f"Download completed: {remote_get_path} -> {local_dest}", module="mtr.sync")
|
|
309
361
|
except SyncError as e:
|
|
@@ -322,43 +374,34 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
322
374
|
click.echo(f"[DryRun] Would run on {host}: {remote_cmd} (workdir={remote_dir})")
|
|
323
375
|
return
|
|
324
376
|
|
|
325
|
-
ssh = SSHClientWrapper(host, user, port=port, key_filename=key_filename, password=password)
|
|
326
377
|
try:
|
|
327
|
-
|
|
328
|
-
logger.info(f"
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
while True:
|
|
347
|
-
line = next(stream)
|
|
348
|
-
click.echo(line, nl=False)
|
|
349
|
-
except StopIteration as e:
|
|
350
|
-
exit_code = e.value
|
|
351
|
-
|
|
352
|
-
logger.info(f"Command completed with exit code: {exit_code}", module="mtr.cli")
|
|
353
|
-
sys.exit(exit_code)
|
|
378
|
+
# Execute command via SSH
|
|
379
|
+
logger.info(f"Executing command: {remote_cmd}", module="mtr.cli")
|
|
380
|
+
exit_code = run_ssh_command(
|
|
381
|
+
host=host,
|
|
382
|
+
user=user,
|
|
383
|
+
command=remote_cmd,
|
|
384
|
+
port=port,
|
|
385
|
+
key_filename=key_filename,
|
|
386
|
+
password=password,
|
|
387
|
+
workdir=remote_dir,
|
|
388
|
+
pre_cmd=pre_cmd,
|
|
389
|
+
tty=is_interactive,
|
|
390
|
+
)
|
|
391
|
+
logger.info(f"Command completed with exit code: {exit_code}", module="mtr.cli")
|
|
392
|
+
|
|
393
|
+
# Show update message if available
|
|
394
|
+
if update_message:
|
|
395
|
+
click.echo(update_message, err=True)
|
|
396
|
+
sys.exit(exit_code)
|
|
354
397
|
|
|
355
398
|
except SSHError as e:
|
|
356
399
|
logger.error(f"SSH error: {e}", module="mtr.ssh")
|
|
357
400
|
click.secho(f"SSH Error: {e}", fg="red", err=True)
|
|
401
|
+
# Show update message if available even on error
|
|
402
|
+
if update_message:
|
|
403
|
+
click.echo(update_message, err=True)
|
|
358
404
|
sys.exit(1)
|
|
359
|
-
finally:
|
|
360
|
-
logger.info("Closing SSH connection", module="mtr.ssh")
|
|
361
|
-
ssh.close()
|
|
362
405
|
|
|
363
406
|
|
|
364
407
|
if __name__ == "__main__":
|
mtr/config.py
CHANGED
|
@@ -19,6 +19,20 @@ class Config:
|
|
|
19
19
|
server_config: Dict[str, Any]
|
|
20
20
|
global_defaults: Dict[str, Any]
|
|
21
21
|
|
|
22
|
+
def get_respect_gitignore(self) -> bool:
|
|
23
|
+
"""Get respect_gitignore setting, default True.
|
|
24
|
+
|
|
25
|
+
Priority: server config > global defaults > True (default)
|
|
26
|
+
"""
|
|
27
|
+
# Check server config first
|
|
28
|
+
if "respect_gitignore" in self.server_config:
|
|
29
|
+
return self.server_config["respect_gitignore"]
|
|
30
|
+
# Then check global defaults
|
|
31
|
+
if "respect_gitignore" in self.global_defaults:
|
|
32
|
+
return self.global_defaults["respect_gitignore"]
|
|
33
|
+
# Default to True
|
|
34
|
+
return True
|
|
35
|
+
|
|
22
36
|
|
|
23
37
|
class ConfigLoader:
|
|
24
38
|
def __init__(self, config_path: Optional[str] = None):
|
mtr/ssh.py
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
|
+
"""SSH utilities for MTRemote."""
|
|
2
|
+
|
|
1
3
|
import os
|
|
2
4
|
import shutil
|
|
3
5
|
import subprocess
|
|
4
|
-
from typing import
|
|
5
|
-
|
|
6
|
-
import paramiko
|
|
6
|
+
from typing import Optional
|
|
7
7
|
|
|
8
8
|
from mtr.logger import get_logger
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class SSHError(Exception):
|
|
12
|
-
|
|
12
|
+
"""SSH-related errors."""
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
# Constants for batch mode
|
|
16
|
-
BUFFER_SIZE = 32768 # 32KB for better performance
|
|
14
|
+
pass
|
|
17
15
|
|
|
18
16
|
|
|
19
17
|
def _check_ssh_availability():
|
|
@@ -38,162 +36,97 @@ def _check_sshpass_availability():
|
|
|
38
36
|
)
|
|
39
37
|
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
return exit_code
|
|
136
|
-
|
|
137
|
-
except paramiko.SSHException as e:
|
|
138
|
-
logger.error(f"Command execution failed: {e}", module="mtr.ssh")
|
|
139
|
-
raise SSHError(f"Command execution failed: {e}")
|
|
140
|
-
|
|
141
|
-
def run_interactive_shell(self, command: str, workdir: Optional[str] = None, pre_cmd: Optional[str] = None) -> int:
|
|
142
|
-
"""
|
|
143
|
-
Runs an interactive shell using system ssh -t command.
|
|
144
|
-
This provides full TTY support with proper signal handling.
|
|
145
|
-
Returns exit code.
|
|
146
|
-
"""
|
|
147
|
-
logger = get_logger()
|
|
148
|
-
logger.info(f"Starting interactive shell via ssh -t: {command}", module="mtr.ssh")
|
|
149
|
-
logger.debug(f"Workdir: {workdir}, Pre-cmd: {pre_cmd}", module="mtr.ssh")
|
|
150
|
-
|
|
151
|
-
# Check SSH availability
|
|
152
|
-
_check_ssh_availability()
|
|
153
|
-
|
|
154
|
-
# Check sshpass availability if password is used
|
|
155
|
-
if self.password:
|
|
156
|
-
_check_sshpass_availability()
|
|
157
|
-
|
|
158
|
-
full_command = self._build_command(command, workdir, pre_cmd)
|
|
159
|
-
|
|
160
|
-
# Build ssh command
|
|
161
|
-
ssh_cmd = ["ssh", "-t"]
|
|
162
|
-
|
|
163
|
-
# Port
|
|
164
|
-
if self.port != 22:
|
|
165
|
-
ssh_cmd.extend(["-p", str(self.port)])
|
|
166
|
-
|
|
167
|
-
# Key authentication
|
|
168
|
-
if self.key_filename:
|
|
169
|
-
ssh_cmd.extend(["-i", os.path.expanduser(self.key_filename)])
|
|
170
|
-
|
|
171
|
-
# Target host and command
|
|
172
|
-
target = f"{self.user}@{self.host}"
|
|
173
|
-
ssh_cmd.extend([target, full_command])
|
|
174
|
-
|
|
175
|
-
# Wrap with sshpass if password is provided
|
|
176
|
-
if self.password:
|
|
177
|
-
ssh_cmd = ["sshpass", "-p", self.password] + ssh_cmd
|
|
178
|
-
|
|
179
|
-
logger.debug(f"Executing: {' '.join(ssh_cmd)}", module="mtr.ssh")
|
|
180
|
-
|
|
181
|
-
# Run command with direct stdin/stdout/stderr forwarding
|
|
182
|
-
try:
|
|
183
|
-
result = subprocess.run(ssh_cmd)
|
|
184
|
-
logger.info(f"Interactive shell exited with code: {result.returncode}", module="mtr.ssh")
|
|
185
|
-
return result.returncode
|
|
186
|
-
except FileNotFoundError as e:
|
|
187
|
-
# This shouldn't happen if _check_ssh_availability passed, but just in case
|
|
188
|
-
logger.error(f"Command not found: {e}", module="mtr.ssh")
|
|
189
|
-
raise SSHError(f"SSH command execution failed: {e}")
|
|
190
|
-
except Exception as e:
|
|
191
|
-
logger.error(f"Interactive shell failed: {e}", module="mtr.ssh")
|
|
192
|
-
raise SSHError(f"Interactive shell failed: {e}")
|
|
193
|
-
|
|
194
|
-
def close(self):
|
|
195
|
-
if self.client:
|
|
196
|
-
logger = get_logger()
|
|
197
|
-
logger.debug("Closing SSH connection", module="mtr.ssh")
|
|
198
|
-
self.client.close()
|
|
199
|
-
logger.debug("SSH connection closed", module="mtr.ssh")
|
|
39
|
+
def _build_command(command: str, workdir: Optional[str] = None, pre_cmd: Optional[str] = None) -> str:
|
|
40
|
+
"""Build the full command string with workdir and pre_cmd."""
|
|
41
|
+
parts = []
|
|
42
|
+
if workdir:
|
|
43
|
+
parts.append(f"cd {workdir}")
|
|
44
|
+
if pre_cmd:
|
|
45
|
+
parts.append(pre_cmd)
|
|
46
|
+
parts.append(command)
|
|
47
|
+
return " && ".join(parts)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_ssh_command(
|
|
51
|
+
host: str,
|
|
52
|
+
user: str,
|
|
53
|
+
command: str,
|
|
54
|
+
port: int = 22,
|
|
55
|
+
key_filename: Optional[str] = None,
|
|
56
|
+
password: Optional[str] = None,
|
|
57
|
+
workdir: Optional[str] = None,
|
|
58
|
+
pre_cmd: Optional[str] = None,
|
|
59
|
+
tty: bool = True,
|
|
60
|
+
) -> int:
|
|
61
|
+
"""
|
|
62
|
+
Run a command on remote host via system SSH.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
host: Remote host address
|
|
66
|
+
user: SSH username
|
|
67
|
+
command: Command to execute
|
|
68
|
+
port: SSH port (default: 22)
|
|
69
|
+
key_filename: Path to SSH private key
|
|
70
|
+
password: SSH password (requires sshpass)
|
|
71
|
+
workdir: Working directory on remote host
|
|
72
|
+
pre_cmd: Command to run before main command
|
|
73
|
+
tty: If True, use ssh -t for TTY allocation
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Exit code from the remote command
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
SSHError: If SSH command fails or is not available
|
|
80
|
+
"""
|
|
81
|
+
logger = get_logger()
|
|
82
|
+
mode_str = "interactive" if tty else "batch"
|
|
83
|
+
logger.info(f"Executing {mode_str} command via SSH: {command}", module="mtr.ssh")
|
|
84
|
+
logger.debug(f"Host: {host}, User: {user}, Port: {port}, TTY: {tty}", module="mtr.ssh")
|
|
85
|
+
|
|
86
|
+
# Check SSH availability
|
|
87
|
+
_check_ssh_availability()
|
|
88
|
+
|
|
89
|
+
# Check sshpass availability if password is used
|
|
90
|
+
if password and not key_filename:
|
|
91
|
+
_check_sshpass_availability()
|
|
92
|
+
|
|
93
|
+
# Build the full command
|
|
94
|
+
full_command = _build_command(command, workdir, pre_cmd)
|
|
95
|
+
logger.debug(f"Full command: {full_command}", module="mtr.ssh")
|
|
96
|
+
|
|
97
|
+
# Build SSH command
|
|
98
|
+
ssh_cmd = ["ssh"]
|
|
99
|
+
|
|
100
|
+
# Add -t flag for TTY mode
|
|
101
|
+
if tty:
|
|
102
|
+
ssh_cmd.append("-t")
|
|
103
|
+
|
|
104
|
+
# Port
|
|
105
|
+
if port != 22:
|
|
106
|
+
ssh_cmd.extend(["-p", str(port)])
|
|
107
|
+
|
|
108
|
+
# Key authentication
|
|
109
|
+
if key_filename:
|
|
110
|
+
ssh_cmd.extend(["-i", os.path.expanduser(key_filename)])
|
|
111
|
+
|
|
112
|
+
# Target host and command
|
|
113
|
+
target = f"{user}@{host}"
|
|
114
|
+
ssh_cmd.extend([target, full_command])
|
|
115
|
+
|
|
116
|
+
# Wrap with sshpass if password is provided
|
|
117
|
+
if password and not key_filename:
|
|
118
|
+
ssh_cmd = ["sshpass", "-p", password] + ssh_cmd
|
|
119
|
+
|
|
120
|
+
logger.debug(f"Executing: {' '.join(ssh_cmd)}", module="mtr.ssh")
|
|
121
|
+
|
|
122
|
+
# Run command
|
|
123
|
+
try:
|
|
124
|
+
result = subprocess.run(ssh_cmd)
|
|
125
|
+
logger.info(f"Command exited with code: {result.returncode}", module="mtr.ssh")
|
|
126
|
+
return result.returncode
|
|
127
|
+
except FileNotFoundError as e:
|
|
128
|
+
logger.error(f"Command not found: {e}", module="mtr.ssh")
|
|
129
|
+
raise SSHError(f"SSH command execution failed: {e}")
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.error(f"SSH command failed: {e}", module="mtr.ssh")
|
|
132
|
+
raise SSHError(f"SSH command failed: {e}")
|