mtr-cli 0.3.0__tar.gz → 2.0.0__tar.gz
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_cli-0.3.0 → mtr_cli-2.0.0}/PKG-INFO +22 -20
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/README.md +20 -18
- mtr_cli-2.0.0/mtr/__init__.py +3 -0
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/mtr/cli.py +123 -127
- mtr_cli-2.0.0/mtr/ssh.py +132 -0
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/mtr/sync.py +0 -240
- mtr_cli-2.0.0/mtr/updater.py +130 -0
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/pyproject.toml +2 -2
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/tests/integration/test_cli_flow.py +24 -29
- mtr_cli-2.0.0/tests/integration/test_cli_phase1.py +67 -0
- mtr_cli-2.0.0/tests/unit/test_ssh.py +25 -0
- mtr_cli-2.0.0/tests/unit/test_updater.py +282 -0
- mtr_cli-2.0.0/uv.lock +424 -0
- mtr_cli-0.3.0/docs/intro-to-mtr.md +0 -180
- mtr_cli-0.3.0/mtr/ssh.py +0 -199
- mtr_cli-0.3.0/tests/integration/test_cli_phase1.py +0 -75
- mtr_cli-0.3.0/tests/unit/__init__.py +0 -0
- mtr_cli-0.3.0/tests/unit/test_ssh.py +0 -60
- mtr_cli-0.3.0/tests/unit/test_ssh_interactive.py +0 -299
- mtr_cli-0.3.0/tests/unit/test_ssh_pre_cmd.py +0 -43
- mtr_cli-0.3.0/tests/unit/test_sync_sftp.py +0 -206
- mtr_cli-0.3.0/uv.lock +0 -709
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/.gitignore +0 -0
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/.pre-commit-config.yaml +0 -0
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/.python-version +0 -0
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/AGENTS.md +0 -0
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/LICENSE +0 -0
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/examples/config.yaml +0 -0
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/mtr/config.py +0 -0
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/mtr/logger.py +0 -0
- {mtr_cli-0.3.0/mtr → mtr_cli-2.0.0/tests}/__init__.py +0 -0
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/tests/conftest.py +0 -0
- {mtr_cli-0.3.0/tests → mtr_cli-2.0.0/tests/integration}/__init__.py +0 -0
- {mtr_cli-0.3.0/tests/integration → mtr_cli-2.0.0/tests/unit}/__init__.py +0 -0
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/tests/unit/test_config.py +0 -0
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/tests/unit/test_logger.py +0 -0
- {mtr_cli-0.3.0 → mtr_cli-2.0.0}/tests/unit/test_sync_rsync.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mtr-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: A CLI tool for seamless local development and remote execution on GPU servers.
|
|
5
5
|
Project-URL: Homepage, https://github.com/lecoan/mtremote
|
|
6
6
|
Project-URL: Repository, https://github.com/lecoan/mtremote
|
|
@@ -20,7 +20,7 @@ Classifier: Topic :: Software Development :: Build Tools
|
|
|
20
20
|
Classifier: Topic :: System :: Systems Administration
|
|
21
21
|
Requires-Python: >=3.10
|
|
22
22
|
Requires-Dist: click>=8.0.0
|
|
23
|
-
Requires-Dist:
|
|
23
|
+
Requires-Dist: packaging>=21.0
|
|
24
24
|
Requires-Dist: pyyaml>=6.0
|
|
25
25
|
Requires-Dist: rich>=12.0.0
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
@@ -33,8 +33,7 @@ MTRemote 是一个专为 AI Infra 和 Python/C++ 混合开发设计的命令行
|
|
|
33
33
|
|
|
34
34
|
* **多服务器管理**:通过配置文件管理多个 GPU 节点,支持默认服务器 (Implicit/Explicit)。
|
|
35
35
|
* **智能同步引擎**:
|
|
36
|
-
* **Rsync
|
|
37
|
-
* **SFTP (兼容)**:纯 Python 实现,适用于无 `rsync` 的环境,配置简单。
|
|
36
|
+
* **Rsync**:调用系统 `rsync`,支持增量同步,速度极快。支持 `sshpass` 自动处理密码认证。
|
|
38
37
|
* **双向同步**:支持从远端下载文件/文件夹到本地(`--get` 参数)。
|
|
39
38
|
* **双模式交互 (Dual-Mode Interaction)**:
|
|
40
39
|
* **交互模式 (Interactive)**:自动检测 TTY,支持 PTY 分配、Raw Mode、Rich UI 动画。完美支持 `vim`, `ipython`, `pdb`, `htop`。
|
|
@@ -93,7 +92,7 @@ mtr --init
|
|
|
93
92
|
|
|
94
93
|
```yaml
|
|
95
94
|
defaults:
|
|
96
|
-
sync: "rsync"
|
|
95
|
+
sync: "rsync"
|
|
97
96
|
exclude: [".git/", "__pycache__/"]
|
|
98
97
|
download_dir: "./downloads" # 默认下载位置(可选)
|
|
99
98
|
|
|
@@ -125,6 +124,21 @@ mtr ipython
|
|
|
125
124
|
mtr -s prod-node python train.py
|
|
126
125
|
```
|
|
127
126
|
|
|
127
|
+
### ⚠️ 参数传递注意事项
|
|
128
|
+
|
|
129
|
+
当执行的命令包含以 `-` 开头的参数时(如 `python -c`, `gcc -O2`),建议使用 `--` 作为分隔符,避免被误认为是 `mtr` 的选项:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# ❌ 错误:-s 会被当作 --server 的短选项
|
|
133
|
+
mtr python3 -s
|
|
134
|
+
|
|
135
|
+
# ✅ 正确:使用 -- 分隔符
|
|
136
|
+
mtr -- python3 -s
|
|
137
|
+
|
|
138
|
+
# ✅ 指定服务器时也使用 --
|
|
139
|
+
mtr --server prod-node -- python3 -c "print('hello')"
|
|
140
|
+
```
|
|
141
|
+
|
|
128
142
|
## 📖 命令行选项
|
|
129
143
|
|
|
130
144
|
```bash
|
|
@@ -169,21 +183,9 @@ mtr --enable-log --log-file ./debug.log python train.py
|
|
|
169
183
|
mtr --no-tty python train.py > log.txt
|
|
170
184
|
```
|
|
171
185
|
|
|
172
|
-
### 2.
|
|
173
|
-
如果本地或远程无法使用 rsync,可以在配置中指定 `sync: sftp`:
|
|
174
|
-
|
|
175
|
-
```yaml
|
|
176
|
-
servers:
|
|
177
|
-
win-server:
|
|
178
|
-
host: "10.0.0.9"
|
|
179
|
-
sync: "sftp"
|
|
180
|
-
password: "secret_password"
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
### 3. 密码认证
|
|
186
|
+
### 2. 密码认证
|
|
184
187
|
支持 SSH 密码认证,但推荐使用 SSH Key。
|
|
185
188
|
* **交互式 Shell**: 使用 `sshpass` 包装 `ssh -t` 命令。
|
|
186
|
-
* **SFTP**: 原生支持密码。
|
|
187
189
|
* **Rsync**: 需要本地安装 `sshpass` 工具才能使用密码认证。
|
|
188
190
|
|
|
189
191
|
**密码认证依赖**: 使用密码认证时,必须安装 `sshpass`:
|
|
@@ -198,7 +200,7 @@ sudo apt-get install sshpass
|
|
|
198
200
|
sudo yum install sshpass
|
|
199
201
|
```
|
|
200
202
|
|
|
201
|
-
###
|
|
203
|
+
### 3. 从远端下载文件 (--get)
|
|
202
204
|
使用 `--get` 参数可以从远端服务器下载文件或文件夹到本地:
|
|
203
205
|
|
|
204
206
|
```bash
|
|
@@ -241,7 +243,7 @@ servers:
|
|
|
241
243
|
3. 默认配置中的 `download_dir`
|
|
242
244
|
4. 当前工作目录
|
|
243
245
|
|
|
244
|
-
###
|
|
246
|
+
### 4. 调试日志 (--enable-log)
|
|
245
247
|
当遇到问题需要排查时,可以启用文件日志:
|
|
246
248
|
|
|
247
249
|
```bash
|
|
@@ -6,8 +6,7 @@ MTRemote 是一个专为 AI Infra 和 Python/C++ 混合开发设计的命令行
|
|
|
6
6
|
|
|
7
7
|
* **多服务器管理**:通过配置文件管理多个 GPU 节点,支持默认服务器 (Implicit/Explicit)。
|
|
8
8
|
* **智能同步引擎**:
|
|
9
|
-
* **Rsync
|
|
10
|
-
* **SFTP (兼容)**:纯 Python 实现,适用于无 `rsync` 的环境,配置简单。
|
|
9
|
+
* **Rsync**:调用系统 `rsync`,支持增量同步,速度极快。支持 `sshpass` 自动处理密码认证。
|
|
11
10
|
* **双向同步**:支持从远端下载文件/文件夹到本地(`--get` 参数)。
|
|
12
11
|
* **双模式交互 (Dual-Mode Interaction)**:
|
|
13
12
|
* **交互模式 (Interactive)**:自动检测 TTY,支持 PTY 分配、Raw Mode、Rich UI 动画。完美支持 `vim`, `ipython`, `pdb`, `htop`。
|
|
@@ -66,7 +65,7 @@ mtr --init
|
|
|
66
65
|
|
|
67
66
|
```yaml
|
|
68
67
|
defaults:
|
|
69
|
-
sync: "rsync"
|
|
68
|
+
sync: "rsync"
|
|
70
69
|
exclude: [".git/", "__pycache__/"]
|
|
71
70
|
download_dir: "./downloads" # 默认下载位置(可选)
|
|
72
71
|
|
|
@@ -98,6 +97,21 @@ mtr ipython
|
|
|
98
97
|
mtr -s prod-node python train.py
|
|
99
98
|
```
|
|
100
99
|
|
|
100
|
+
### ⚠️ 参数传递注意事项
|
|
101
|
+
|
|
102
|
+
当执行的命令包含以 `-` 开头的参数时(如 `python -c`, `gcc -O2`),建议使用 `--` 作为分隔符,避免被误认为是 `mtr` 的选项:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# ❌ 错误:-s 会被当作 --server 的短选项
|
|
106
|
+
mtr python3 -s
|
|
107
|
+
|
|
108
|
+
# ✅ 正确:使用 -- 分隔符
|
|
109
|
+
mtr -- python3 -s
|
|
110
|
+
|
|
111
|
+
# ✅ 指定服务器时也使用 --
|
|
112
|
+
mtr --server prod-node -- python3 -c "print('hello')"
|
|
113
|
+
```
|
|
114
|
+
|
|
101
115
|
## 📖 命令行选项
|
|
102
116
|
|
|
103
117
|
```bash
|
|
@@ -142,21 +156,9 @@ mtr --enable-log --log-file ./debug.log python train.py
|
|
|
142
156
|
mtr --no-tty python train.py > log.txt
|
|
143
157
|
```
|
|
144
158
|
|
|
145
|
-
### 2.
|
|
146
|
-
如果本地或远程无法使用 rsync,可以在配置中指定 `sync: sftp`:
|
|
147
|
-
|
|
148
|
-
```yaml
|
|
149
|
-
servers:
|
|
150
|
-
win-server:
|
|
151
|
-
host: "10.0.0.9"
|
|
152
|
-
sync: "sftp"
|
|
153
|
-
password: "secret_password"
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
### 3. 密码认证
|
|
159
|
+
### 2. 密码认证
|
|
157
160
|
支持 SSH 密码认证,但推荐使用 SSH Key。
|
|
158
161
|
* **交互式 Shell**: 使用 `sshpass` 包装 `ssh -t` 命令。
|
|
159
|
-
* **SFTP**: 原生支持密码。
|
|
160
162
|
* **Rsync**: 需要本地安装 `sshpass` 工具才能使用密码认证。
|
|
161
163
|
|
|
162
164
|
**密码认证依赖**: 使用密码认证时,必须安装 `sshpass`:
|
|
@@ -171,7 +173,7 @@ sudo apt-get install sshpass
|
|
|
171
173
|
sudo yum install sshpass
|
|
172
174
|
```
|
|
173
175
|
|
|
174
|
-
###
|
|
176
|
+
### 3. 从远端下载文件 (--get)
|
|
175
177
|
使用 `--get` 参数可以从远端服务器下载文件或文件夹到本地:
|
|
176
178
|
|
|
177
179
|
```bash
|
|
@@ -214,7 +216,7 @@ servers:
|
|
|
214
216
|
3. 默认配置中的 `download_dir`
|
|
215
217
|
4. 当前工作目录
|
|
216
218
|
|
|
217
|
-
###
|
|
219
|
+
### 4. 调试日志 (--enable-log)
|
|
218
220
|
当遇到问题需要排查时,可以启用文件日志:
|
|
219
221
|
|
|
220
222
|
```bash
|
|
@@ -4,20 +4,20 @@ 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
|
|
|
18
|
-
# 是否尊重 .gitignore
|
|
19
|
+
# 是否尊重 .gitignore 文件
|
|
19
20
|
# 设置为 true 时,rsync 会自动读取项目根目录的 .gitignore 并排除匹配的文件
|
|
20
|
-
# SFTP 模式不支持此选项,如启用会报错
|
|
21
21
|
respect_gitignore: true
|
|
22
22
|
|
|
23
23
|
exclude:
|
|
@@ -39,12 +39,9 @@ servers:
|
|
|
39
39
|
# 预设命令 (可选)
|
|
40
40
|
# pre_cmd: "source ~/.bashrc && conda activate myenv"
|
|
41
41
|
|
|
42
|
-
# 密码认证 (
|
|
42
|
+
# 密码认证 (可选,需要安装 sshpass)
|
|
43
43
|
# password: "secret"
|
|
44
44
|
|
|
45
|
-
# 强制同步引擎 (可选)
|
|
46
|
-
# sync: "sftp"
|
|
47
|
-
|
|
48
45
|
# 该服务器的下载位置(可选,覆盖 defaults)
|
|
49
46
|
# download_dir: "./backups/dev-node"
|
|
50
47
|
"""
|
|
@@ -71,6 +68,7 @@ def _init_config():
|
|
|
71
68
|
|
|
72
69
|
|
|
73
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.")
|
|
74
72
|
@click.option("-s", "--server", help="Target server alias")
|
|
75
73
|
@click.option("--sync/--no-sync", default=True, help="Enable/Disable code sync")
|
|
76
74
|
@click.option("--dry-run", is_flag=True, help="Print commands without executing")
|
|
@@ -81,10 +79,36 @@ def _init_config():
|
|
|
81
79
|
@click.option("--log-file", help="Path to log file (default: ./.mtr/logs/mtr_YYYYMMDD_HHMMSS.log)")
|
|
82
80
|
@click.option("--get", "remote_get_path", help="Remote path to download from")
|
|
83
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")
|
|
84
83
|
@click.argument("command", nargs=-1, type=click.UNPROCESSED)
|
|
85
|
-
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
|
+
):
|
|
86
98
|
"""MTRemote: Sync and Execute code on remote server."""
|
|
87
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
|
+
|
|
88
112
|
# Get logger instance (will be no-op if not setup)
|
|
89
113
|
logger = get_logger()
|
|
90
114
|
|
|
@@ -197,71 +221,61 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
197
221
|
# Determine engine
|
|
198
222
|
engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
|
|
199
223
|
|
|
200
|
-
if
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
remote_dir=remote_dir,
|
|
204
|
-
host=host,
|
|
205
|
-
user=user,
|
|
206
|
-
key_filename=key_filename,
|
|
207
|
-
password=password,
|
|
208
|
-
port=port,
|
|
209
|
-
exclude=exclude,
|
|
210
|
-
respect_gitignore=respect_gitignore,
|
|
211
|
-
)
|
|
212
|
-
elif engine == "sftp":
|
|
213
|
-
syncer = SftpSyncer(
|
|
214
|
-
local_dir=local_dir,
|
|
215
|
-
remote_dir=remote_dir,
|
|
216
|
-
host=host,
|
|
217
|
-
user=user,
|
|
218
|
-
key_filename=key_filename,
|
|
219
|
-
password=password,
|
|
220
|
-
port=port,
|
|
221
|
-
exclude=exclude,
|
|
222
|
-
respect_gitignore=respect_gitignore,
|
|
223
|
-
)
|
|
224
|
-
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")
|
|
225
227
|
click.secho(
|
|
226
|
-
|
|
227
|
-
fg="
|
|
228
|
+
"Error: SFTP mode has been removed. Please update your config to use 'sync: rsync'.",
|
|
229
|
+
fg="red",
|
|
230
|
+
err=True,
|
|
228
231
|
)
|
|
229
|
-
|
|
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:
|
|
230
257
|
|
|
231
|
-
if syncer:
|
|
232
|
-
try:
|
|
233
|
-
if dry_run:
|
|
234
|
-
click.echo(f"[DryRun] Would sync {local_dir} -> {remote_dir}")
|
|
235
|
-
logger.info(f"[DryRun] Would sync {local_dir} -> {remote_dir}", module="mtr.sync")
|
|
236
|
-
else:
|
|
237
|
-
if is_interactive and console:
|
|
238
|
-
# TTY mode: single line real-time update using Rich Live
|
|
239
|
-
from rich.live import Live
|
|
240
|
-
from rich.text import Text
|
|
241
|
-
|
|
242
|
-
with Live(Text("Starting sync...", style="blue"), refresh_per_second=10) as live:
|
|
243
|
-
|
|
244
|
-
def show_sync_progress(filename):
|
|
245
|
-
# Get relative path for cleaner display
|
|
246
|
-
rel_path = os.path.relpath(filename, local_dir)
|
|
247
|
-
live.update(Text(f"Syncing: {rel_path}", style="blue"))
|
|
248
|
-
|
|
249
|
-
syncer.sync(show_progress=True, progress_callback=show_sync_progress)
|
|
250
|
-
live.update(Text("Sync completed!", style="green"))
|
|
251
|
-
else:
|
|
252
|
-
# no_tty mode: print each file on new line
|
|
253
258
|
def show_sync_progress(filename):
|
|
259
|
+
# Get relative path for cleaner display
|
|
254
260
|
rel_path = os.path.relpath(filename, local_dir)
|
|
255
|
-
|
|
261
|
+
live.update(Text(f"Syncing: {rel_path}", style="blue"))
|
|
256
262
|
|
|
257
|
-
click.secho("Syncing code...", fg="blue")
|
|
258
263
|
syncer.sync(show_progress=True, progress_callback=show_sync_progress)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
264
|
+
live.update(Text("Sync completed!", style="green"))
|
|
265
|
+
else:
|
|
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)
|
|
265
279
|
|
|
266
280
|
# 3. Download from remote (if --get is specified)
|
|
267
281
|
if remote_get_path:
|
|
@@ -293,37 +307,28 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
293
307
|
# Determine engine
|
|
294
308
|
engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
|
|
295
309
|
|
|
296
|
-
if
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
remote_dir=".", # Not used for download
|
|
300
|
-
host=host,
|
|
301
|
-
user=user,
|
|
302
|
-
key_filename=key_filename,
|
|
303
|
-
password=password,
|
|
304
|
-
port=port,
|
|
305
|
-
exclude=exclude,
|
|
306
|
-
respect_gitignore=respect_gitignore,
|
|
307
|
-
)
|
|
308
|
-
elif engine == "sftp":
|
|
309
|
-
syncer = SftpSyncer(
|
|
310
|
-
local_dir=".", # Not used for download
|
|
311
|
-
remote_dir=".", # Not used for download
|
|
312
|
-
host=host,
|
|
313
|
-
user=user,
|
|
314
|
-
key_filename=key_filename,
|
|
315
|
-
password=password,
|
|
316
|
-
port=port,
|
|
317
|
-
exclude=exclude,
|
|
318
|
-
respect_gitignore=respect_gitignore,
|
|
319
|
-
)
|
|
320
|
-
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")
|
|
321
313
|
click.secho(
|
|
322
|
-
|
|
323
|
-
fg="
|
|
314
|
+
"Error: SFTP mode has been removed. Please update your config to use 'sync: rsync'.",
|
|
315
|
+
fg="red",
|
|
316
|
+
err=True,
|
|
324
317
|
)
|
|
325
318
|
sys.exit(1)
|
|
326
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
|
+
|
|
327
332
|
try:
|
|
328
333
|
if dry_run:
|
|
329
334
|
click.echo(f"[DryRun] Would download {remote_get_path} -> {local_dest}")
|
|
@@ -369,43 +374,34 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
369
374
|
click.echo(f"[DryRun] Would run on {host}: {remote_cmd} (workdir={remote_dir})")
|
|
370
375
|
return
|
|
371
376
|
|
|
372
|
-
ssh = SSHClientWrapper(host, user, port=port, key_filename=key_filename, password=password)
|
|
373
377
|
try:
|
|
374
|
-
|
|
375
|
-
logger.info(f"
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
while True:
|
|
394
|
-
line = next(stream)
|
|
395
|
-
click.echo(line, nl=False)
|
|
396
|
-
except StopIteration as e:
|
|
397
|
-
exit_code = e.value
|
|
398
|
-
|
|
399
|
-
logger.info(f"Command completed with exit code: {exit_code}", module="mtr.cli")
|
|
400
|
-
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)
|
|
401
397
|
|
|
402
398
|
except SSHError as e:
|
|
403
399
|
logger.error(f"SSH error: {e}", module="mtr.ssh")
|
|
404
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)
|
|
405
404
|
sys.exit(1)
|
|
406
|
-
finally:
|
|
407
|
-
logger.info("Closing SSH connection", module="mtr.ssh")
|
|
408
|
-
ssh.close()
|
|
409
405
|
|
|
410
406
|
|
|
411
407
|
if __name__ == "__main__":
|
mtr_cli-2.0.0/mtr/ssh.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""SSH utilities for MTRemote."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from mtr.logger import get_logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SSHError(Exception):
|
|
12
|
+
"""SSH-related errors."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _check_ssh_availability():
|
|
18
|
+
"""Check if ssh command is available."""
|
|
19
|
+
if shutil.which("ssh") is None:
|
|
20
|
+
raise SSHError(
|
|
21
|
+
"SSH command not found. Please install OpenSSH client.\n"
|
|
22
|
+
" macOS: brew install openssh\n"
|
|
23
|
+
" Ubuntu/Debian: sudo apt-get install openssh-client\n"
|
|
24
|
+
" CentOS/RHEL: sudo yum install openssh-clients"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_sshpass_availability():
|
|
29
|
+
"""Check if sshpass command is available."""
|
|
30
|
+
if shutil.which("sshpass") is None:
|
|
31
|
+
raise SSHError(
|
|
32
|
+
"sshpass command not found. Please install sshpass for password authentication.\n"
|
|
33
|
+
" macOS: brew install hudochenkov/sshpass/sshpass\n"
|
|
34
|
+
" Ubuntu/Debian: sudo apt-get install sshpass\n"
|
|
35
|
+
" CentOS/RHEL: sudo yum install sshpass"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
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}")
|