compshare-cli 0.3.0__tar.gz → 0.3.2__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.
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/PKG-INFO +23 -3
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/README.md +22 -2
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/pyproject.toml +1 -1
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/__init__.py +1 -1
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/doctor.py +13 -1
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/instance.py +143 -5
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/i18n.py +12 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/output.py +18 -2
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/ssh.py +90 -3
- compshare_cli-0.3.2/src/compshare_cli/ssh_cache.py +218 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/PKG-INFO +23 -3
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/SOURCES.txt +3 -1
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_cli.py +144 -1
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_output.py +13 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_ssh.py +58 -0
- compshare_cli-0.3.2/tests/test_ssh_cache.py +47 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/LICENSE +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/setup.cfg +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/__main__.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/actions.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/api.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/cli.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/__init__.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/ask.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/common.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/feedback.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/image.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/storage.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/commands/team.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/config.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/errors.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/insights.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/location.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/parsing.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/qa.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/runtime.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli/sdk.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/dependency_links.txt +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/entry_points.txt +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/requires.txt +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/top_level.txt +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_actions.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_api.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_config.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_feedback.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_location.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_parsing.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_qa.py +0 -0
- {compshare_cli-0.3.0 → compshare_cli-0.3.2}/tests/test_sdk.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: compshare-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Command-line client for CompShare GPU compute
|
|
5
5
|
Author: CompShare
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -35,7 +35,7 @@ Dynamic: license-file
|
|
|
35
35
|
- 可通过 `compshare feedback` 反馈 CLI 问题和建议
|
|
36
36
|
- 基于官方 [`ucloud-sdk-python3`](https://github.com/ucloud/ucloud-sdk-python3)
|
|
37
37
|
|
|
38
|
-
当前版本:`0.3.
|
|
38
|
+
当前版本:`0.3.1`,要求 Python 3.9 或更高版本。
|
|
39
39
|
|
|
40
40
|
## 安装
|
|
41
41
|
|
|
@@ -54,12 +54,23 @@ source .venv/bin/activate
|
|
|
54
54
|
python -m pip install -e '.[dev]'
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
Windows
|
|
57
|
+
Windows PowerShell 中可先激活虚拟环境,再直接调用 `compshare`:
|
|
58
58
|
|
|
59
59
|
```powershell
|
|
60
60
|
.\.venv\Scripts\Activate.ps1
|
|
61
|
+
compshare -h
|
|
62
|
+
compshare config --name default
|
|
61
63
|
```
|
|
62
64
|
|
|
65
|
+
不激活虚拟环境时,使用 PowerShell 调用运算符 `&` 直接执行虚拟环境内的入口:
|
|
66
|
+
|
|
67
|
+
```powershell
|
|
68
|
+
& '.\.venv\Scripts\compshare.exe' -h
|
|
69
|
+
& '.\.venv\Scripts\compshare.exe' config --name default
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
PowerShell 会把单引号中的路径解析为字符串,所以带参数执行时不能省略 `&`。路径包含空格时必须保留引号;从当前目录运行可执行文件时要使用 `.\` 前缀。若 `Activate.ps1` 被本机执行策略拦截,可直接使用上述不激活的写法,无需修改系统策略。
|
|
73
|
+
|
|
63
74
|
## 配置
|
|
64
75
|
|
|
65
76
|
```bash
|
|
@@ -105,6 +116,11 @@ compshare instance ssh INSTANCE_ID --no-auto-password
|
|
|
105
116
|
# 自动登录后执行远程命令;远程参数用 -- 与 CLI 选项分隔
|
|
106
117
|
compshare instance ssh INSTANCE_ID -- nvidia-smi --query-gpu=name
|
|
107
118
|
compshare instance ssh INSTANCE_ID -- 'cd /workspace && python train.py'
|
|
119
|
+
# Agent/脚本:执行并返回 ok、phase、exit_code、stdout、stderr、error
|
|
120
|
+
compshare --json instance ssh INSTANCE_ID -- nvidia-smi
|
|
121
|
+
# 强制刷新或禁用本地 SSH 连接缓存
|
|
122
|
+
compshare instance ssh INSTANCE_ID --refresh
|
|
123
|
+
compshare instance ssh INSTANCE_ID --no-cache
|
|
108
124
|
# 上传本地文件或目录,目录会自动递归复制
|
|
109
125
|
compshare instance scp INSTANCE_ID ./model.bin /workspace/model.bin
|
|
110
126
|
compshare instance scp INSTANCE_ID ./dataset /workspace/dataset
|
|
@@ -219,6 +235,10 @@ compshare lang # 查看当前语言
|
|
|
219
235
|
- `instance ssh` 在支持的交互式终端中通过伪终端自动填写登录密码,不会打印密码或将其放入进程参数。
|
|
220
236
|
- 自动登录会接受首次出现的 SSH 主机密钥;已记录主机的密钥发生变化时仍会拒绝连接。
|
|
221
237
|
- `instance ssh INSTANCE_ID -- COMMAND` 可非交互执行远程命令,透传命令输出和退出码。
|
|
238
|
+
- `--json instance ssh INSTANCE_ID -- COMMAND` 会真实执行命令,并结构化返回退出码、标准输出、标准错误及连接/认证错误阶段。
|
|
239
|
+
- `--json` 始终以 UTF-8 字节输出,不依赖 Windows 活动代码页或终端编码。
|
|
240
|
+
- `instance ssh` 默认将 API 返回的 SSH 命令和密码按 profile/实例缓存 1 小时;Windows 使用当前用户的 DPAPI 加密密码,其他平台将缓存文件权限限制为当前用户。可用 `--refresh` 强制更新、`--no-cache` 禁用;重置密码和重装实例会清除对应缓存。
|
|
241
|
+
- 实例创建是异步操作;`instance create` 默认等待到 `Running`,显式使用 `--no-wait` 才会在创建接口返回后立即退出。`instance ssh` 也会默认等待实例运行后再连接。
|
|
222
242
|
- `instance scp INSTANCE_ID LOCAL_PATH REMOTE_PATH` 可自动认证并上传本地文件或目录。
|
|
223
243
|
- `--show-sensitive` 会恢复这些字段的原始值;请勿在共享终端、CI 日志或 Agent 会话中使用。
|
|
224
244
|
- 删除、关机、重启、重装和改配等操作默认要求确认。
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
- 可通过 `compshare feedback` 反馈 CLI 问题和建议
|
|
11
11
|
- 基于官方 [`ucloud-sdk-python3`](https://github.com/ucloud/ucloud-sdk-python3)
|
|
12
12
|
|
|
13
|
-
当前版本:`0.3.
|
|
13
|
+
当前版本:`0.3.1`,要求 Python 3.9 或更高版本。
|
|
14
14
|
|
|
15
15
|
## 安装
|
|
16
16
|
|
|
@@ -29,12 +29,23 @@ source .venv/bin/activate
|
|
|
29
29
|
python -m pip install -e '.[dev]'
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
Windows
|
|
32
|
+
Windows PowerShell 中可先激活虚拟环境,再直接调用 `compshare`:
|
|
33
33
|
|
|
34
34
|
```powershell
|
|
35
35
|
.\.venv\Scripts\Activate.ps1
|
|
36
|
+
compshare -h
|
|
37
|
+
compshare config --name default
|
|
36
38
|
```
|
|
37
39
|
|
|
40
|
+
不激活虚拟环境时,使用 PowerShell 调用运算符 `&` 直接执行虚拟环境内的入口:
|
|
41
|
+
|
|
42
|
+
```powershell
|
|
43
|
+
& '.\.venv\Scripts\compshare.exe' -h
|
|
44
|
+
& '.\.venv\Scripts\compshare.exe' config --name default
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
PowerShell 会把单引号中的路径解析为字符串,所以带参数执行时不能省略 `&`。路径包含空格时必须保留引号;从当前目录运行可执行文件时要使用 `.\` 前缀。若 `Activate.ps1` 被本机执行策略拦截,可直接使用上述不激活的写法,无需修改系统策略。
|
|
48
|
+
|
|
38
49
|
## 配置
|
|
39
50
|
|
|
40
51
|
```bash
|
|
@@ -80,6 +91,11 @@ compshare instance ssh INSTANCE_ID --no-auto-password
|
|
|
80
91
|
# 自动登录后执行远程命令;远程参数用 -- 与 CLI 选项分隔
|
|
81
92
|
compshare instance ssh INSTANCE_ID -- nvidia-smi --query-gpu=name
|
|
82
93
|
compshare instance ssh INSTANCE_ID -- 'cd /workspace && python train.py'
|
|
94
|
+
# Agent/脚本:执行并返回 ok、phase、exit_code、stdout、stderr、error
|
|
95
|
+
compshare --json instance ssh INSTANCE_ID -- nvidia-smi
|
|
96
|
+
# 强制刷新或禁用本地 SSH 连接缓存
|
|
97
|
+
compshare instance ssh INSTANCE_ID --refresh
|
|
98
|
+
compshare instance ssh INSTANCE_ID --no-cache
|
|
83
99
|
# 上传本地文件或目录,目录会自动递归复制
|
|
84
100
|
compshare instance scp INSTANCE_ID ./model.bin /workspace/model.bin
|
|
85
101
|
compshare instance scp INSTANCE_ID ./dataset /workspace/dataset
|
|
@@ -194,6 +210,10 @@ compshare lang # 查看当前语言
|
|
|
194
210
|
- `instance ssh` 在支持的交互式终端中通过伪终端自动填写登录密码,不会打印密码或将其放入进程参数。
|
|
195
211
|
- 自动登录会接受首次出现的 SSH 主机密钥;已记录主机的密钥发生变化时仍会拒绝连接。
|
|
196
212
|
- `instance ssh INSTANCE_ID -- COMMAND` 可非交互执行远程命令,透传命令输出和退出码。
|
|
213
|
+
- `--json instance ssh INSTANCE_ID -- COMMAND` 会真实执行命令,并结构化返回退出码、标准输出、标准错误及连接/认证错误阶段。
|
|
214
|
+
- `--json` 始终以 UTF-8 字节输出,不依赖 Windows 活动代码页或终端编码。
|
|
215
|
+
- `instance ssh` 默认将 API 返回的 SSH 命令和密码按 profile/实例缓存 1 小时;Windows 使用当前用户的 DPAPI 加密密码,其他平台将缓存文件权限限制为当前用户。可用 `--refresh` 强制更新、`--no-cache` 禁用;重置密码和重装实例会清除对应缓存。
|
|
216
|
+
- 实例创建是异步操作;`instance create` 默认等待到 `Running`,显式使用 `--no-wait` 才会在创建接口返回后立即退出。`instance ssh` 也会默认等待实例运行后再连接。
|
|
197
217
|
- `instance scp INSTANCE_ID LOCAL_PATH REMOTE_PATH` 可自动认证并上传本地文件或目录。
|
|
198
218
|
- `--show-sensitive` 会恢复这些字段的原始值;请勿在共享终端、CI 日志或 Agent 会话中使用。
|
|
199
219
|
- 删除、关机、重启、重装和改配等操作默认要求确认。
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import platform
|
|
4
5
|
import shutil
|
|
5
6
|
import stat
|
|
6
7
|
import sys
|
|
@@ -61,7 +62,18 @@ def run(state: Runtime) -> None:
|
|
|
61
62
|
)
|
|
62
63
|
)
|
|
63
64
|
|
|
64
|
-
if store.path.exists():
|
|
65
|
+
if store.path.exists() and platform.system() == "Windows":
|
|
66
|
+
checks.append(
|
|
67
|
+
_check(
|
|
68
|
+
"Permissions",
|
|
69
|
+
"Warning",
|
|
70
|
+
tr(
|
|
71
|
+
"Config {path}: Windows ACLs apply; POSIX permission check skipped.",
|
|
72
|
+
path=store.path,
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
elif store.path.exists():
|
|
65
77
|
file_mode = stat.S_IMODE(store.path.stat().st_mode)
|
|
66
78
|
directory_mode = stat.S_IMODE(store.path.parent.stat().st_mode)
|
|
67
79
|
permissions_ok = file_mode & 0o077 == 0 and directory_mode & 0o077 == 0
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import shlex
|
|
4
5
|
import subprocess
|
|
5
6
|
import sys
|
|
6
7
|
import time
|
|
7
8
|
from decimal import Decimal, InvalidOperation
|
|
9
|
+
from hashlib import sha256
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, TypeVar
|
|
10
12
|
|
|
@@ -12,6 +14,7 @@ import typer
|
|
|
12
14
|
|
|
13
15
|
from compshare_cli.api import call, call_captured, collect_pages, invoke
|
|
14
16
|
from compshare_cli.commands.common import confirm, confirm_details, request, runtime
|
|
17
|
+
from compshare_cli.config import ConfigStore
|
|
15
18
|
from compshare_cli.errors import UsageError
|
|
16
19
|
from compshare_cli.i18n import tr
|
|
17
20
|
from compshare_cli.location import instance_location, locate_instance, supported_locations
|
|
@@ -30,9 +33,12 @@ from compshare_cli.ssh import (
|
|
|
30
33
|
PasswordAutomationUnavailable,
|
|
31
34
|
connect_with_password,
|
|
32
35
|
copy_with_password,
|
|
36
|
+
execute_captured,
|
|
37
|
+
execute_captured_with_password,
|
|
33
38
|
execute_with_password,
|
|
34
39
|
scp_upload_command,
|
|
35
40
|
)
|
|
41
|
+
from compshare_cli.ssh_cache import DEFAULT_TTL, SSHCredentialCache
|
|
36
42
|
|
|
37
43
|
app = typer.Typer(help="Manage GPU instances.", no_args_is_help=True)
|
|
38
44
|
ports_app = typer.Typer(help="Manage container port mappings.", no_args_is_help=True)
|
|
@@ -203,6 +209,41 @@ def _wait_enabled(state: Runtime, value: Optional[bool]) -> bool:
|
|
|
203
209
|
return not state.json_output and sys.stdout.isatty()
|
|
204
210
|
|
|
205
211
|
|
|
212
|
+
def _create_wait_enabled(value: Optional[bool]) -> bool:
|
|
213
|
+
"""Creation is asynchronous, so wait by default in both human and Agent modes."""
|
|
214
|
+
return True if value is None else value
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _ssh_cache_profile(state: Runtime) -> str:
|
|
218
|
+
name = (
|
|
219
|
+
state.profile_name or os.environ.get("COMPSHARE_PROFILE") or ConfigStore().current_profile()
|
|
220
|
+
)
|
|
221
|
+
credential = sha256(state.profile.public_key.encode("utf-8")).hexdigest()[:16]
|
|
222
|
+
return f"{name}:{credential}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _locate_ssh_instance(
|
|
226
|
+
state: Runtime,
|
|
227
|
+
instance: str,
|
|
228
|
+
*,
|
|
229
|
+
use_cache: bool,
|
|
230
|
+
refresh: bool,
|
|
231
|
+
cache_ttl: int,
|
|
232
|
+
) -> Tuple[str, str, Dict[str, Any], str]:
|
|
233
|
+
cache = SSHCredentialCache()
|
|
234
|
+
profile = _ssh_cache_profile(state)
|
|
235
|
+
if use_cache and not refresh:
|
|
236
|
+
host = cache.get(profile, instance, ttl=cache_ttl)
|
|
237
|
+
if host is not None:
|
|
238
|
+
region, zone = instance_location(host, instance)
|
|
239
|
+
return region, zone, host, "cache"
|
|
240
|
+
|
|
241
|
+
region, zone, host = locate_instance(state, instance)
|
|
242
|
+
if use_cache:
|
|
243
|
+
cache.put(profile, instance, host)
|
|
244
|
+
return region, zone, host, "api"
|
|
245
|
+
|
|
246
|
+
|
|
206
247
|
def _wait_for_instance(
|
|
207
248
|
state: Runtime,
|
|
208
249
|
instance: str,
|
|
@@ -937,9 +978,16 @@ def create(
|
|
|
937
978
|
ids = created.get("UHostIds") or created.get("UHostId") or []
|
|
938
979
|
if isinstance(ids, str):
|
|
939
980
|
ids = [ids]
|
|
940
|
-
if
|
|
981
|
+
if _create_wait_enabled(wait):
|
|
941
982
|
result["final"] = [
|
|
942
|
-
_wait_for_instance(
|
|
983
|
+
_wait_for_instance(
|
|
984
|
+
state,
|
|
985
|
+
item,
|
|
986
|
+
region=region,
|
|
987
|
+
desired={"Running"},
|
|
988
|
+
timeout=timeout,
|
|
989
|
+
)
|
|
990
|
+
for item in ids
|
|
943
991
|
]
|
|
944
992
|
Renderer(state.json_output, state.show_sensitive).details(
|
|
945
993
|
"Operation completed",
|
|
@@ -1286,6 +1334,7 @@ def password(
|
|
|
1286
1334
|
yes=yes,
|
|
1287
1335
|
extra={"Password": encode_password(secret)},
|
|
1288
1336
|
)
|
|
1337
|
+
SSHCredentialCache().delete(_ssh_cache_profile(runtime(ctx)), instance)
|
|
1289
1338
|
|
|
1290
1339
|
|
|
1291
1340
|
@app.command("reinstall", help="Reinstall an instance from an image.")
|
|
@@ -1318,6 +1367,7 @@ def reinstall(
|
|
|
1318
1367
|
wait=wait,
|
|
1319
1368
|
timeout=timeout,
|
|
1320
1369
|
)
|
|
1370
|
+
SSHCredentialCache().delete(_ssh_cache_profile(runtime(ctx)), instance)
|
|
1321
1371
|
|
|
1322
1372
|
|
|
1323
1373
|
@app.command("resize", help="Change instance CPU, memory, GPU or disk size.")
|
|
@@ -1758,16 +1808,65 @@ def ssh(
|
|
|
1758
1808
|
"--auto-password/--no-auto-password",
|
|
1759
1809
|
help="Automatically enter the password returned by the API.",
|
|
1760
1810
|
),
|
|
1811
|
+
wait: bool = typer.Option(
|
|
1812
|
+
True,
|
|
1813
|
+
"--wait/--no-wait",
|
|
1814
|
+
help="Wait for the instance to be running before connecting.",
|
|
1815
|
+
),
|
|
1816
|
+
timeout: int = typer.Option(600, "--timeout", min=1, help="Maximum wait time in seconds."),
|
|
1817
|
+
connect_timeout: int = typer.Option(
|
|
1818
|
+
30,
|
|
1819
|
+
"--connect-timeout",
|
|
1820
|
+
min=1,
|
|
1821
|
+
help="Maximum SSH connection time in seconds.",
|
|
1822
|
+
),
|
|
1823
|
+
use_cache: bool = typer.Option(
|
|
1824
|
+
True,
|
|
1825
|
+
"--cache/--no-cache",
|
|
1826
|
+
help="Cache SSH connection data to avoid repeated instance queries.",
|
|
1827
|
+
),
|
|
1828
|
+
cache_ttl: int = typer.Option(
|
|
1829
|
+
DEFAULT_TTL,
|
|
1830
|
+
"--cache-ttl",
|
|
1831
|
+
min=1,
|
|
1832
|
+
help="SSH connection cache lifetime in seconds.",
|
|
1833
|
+
),
|
|
1834
|
+
refresh: bool = typer.Option(
|
|
1835
|
+
False,
|
|
1836
|
+
"--refresh",
|
|
1837
|
+
help="Refresh SSH connection data from the API before connecting.",
|
|
1838
|
+
),
|
|
1761
1839
|
) -> None:
|
|
1762
1840
|
state = runtime(ctx)
|
|
1763
|
-
region, zone, host =
|
|
1841
|
+
region, zone, host, credential_source = _locate_ssh_instance(
|
|
1842
|
+
state,
|
|
1843
|
+
instance,
|
|
1844
|
+
use_cache=use_cache,
|
|
1845
|
+
refresh=refresh,
|
|
1846
|
+
cache_ttl=cache_ttl,
|
|
1847
|
+
)
|
|
1848
|
+
if wait and host.get("State") and str(host.get("State")) != "Running":
|
|
1849
|
+
ready = _wait_for_instance(
|
|
1850
|
+
state,
|
|
1851
|
+
instance,
|
|
1852
|
+
region=region,
|
|
1853
|
+
desired={"Running"},
|
|
1854
|
+
timeout=timeout,
|
|
1855
|
+
)
|
|
1856
|
+
hosts = ready.get("UHostSet") or []
|
|
1857
|
+
if hosts:
|
|
1858
|
+
host = hosts[0]
|
|
1859
|
+
if use_cache:
|
|
1860
|
+
SSHCredentialCache().put(_ssh_cache_profile(state), instance, host)
|
|
1764
1861
|
command = host.get("SshLoginCommand")
|
|
1765
1862
|
raw_password = host.get("Password")
|
|
1766
1863
|
password = decode_password(str(raw_password)) if raw_password is not None else None
|
|
1767
1864
|
if not command:
|
|
1768
1865
|
raise UsageError(tr("Instance {instance} has no SSH login command.", instance=instance))
|
|
1769
1866
|
argv = [*shlex.split(command), *(remote_command or [])]
|
|
1770
|
-
if
|
|
1867
|
+
if remote_command:
|
|
1868
|
+
argv[1:1] = ["-o", f"ConnectTimeout={connect_timeout}"]
|
|
1869
|
+
if print_only or (state.json_output and not remote_command):
|
|
1771
1870
|
Renderer(state.json_output, state.show_sensitive).data(
|
|
1772
1871
|
{
|
|
1773
1872
|
"instance": instance,
|
|
@@ -1775,9 +1874,43 @@ def ssh(
|
|
|
1775
1874
|
if not remote_command
|
|
1776
1875
|
else f"{command} {shlex.join(remote_command)}",
|
|
1777
1876
|
"password": password,
|
|
1877
|
+
"credential_source": credential_source,
|
|
1778
1878
|
}
|
|
1779
1879
|
)
|
|
1780
1880
|
return
|
|
1881
|
+
if remote_command and state.json_output:
|
|
1882
|
+
execution = (
|
|
1883
|
+
execute_captured_with_password(argv, str(password))
|
|
1884
|
+
if password and auto_password
|
|
1885
|
+
else execute_captured(argv)
|
|
1886
|
+
)
|
|
1887
|
+
error = None
|
|
1888
|
+
if not execution.ok:
|
|
1889
|
+
message = execution.stderr.strip() or f"SSH exited with status {execution.exit_code}."
|
|
1890
|
+
error = {
|
|
1891
|
+
"phase": execution.phase,
|
|
1892
|
+
"code": execution.error_code,
|
|
1893
|
+
"message": message,
|
|
1894
|
+
}
|
|
1895
|
+
if credential_source == "cache" and execution.phase in {
|
|
1896
|
+
"authentication",
|
|
1897
|
+
"connection",
|
|
1898
|
+
"ssh",
|
|
1899
|
+
}:
|
|
1900
|
+
SSHCredentialCache().delete(_ssh_cache_profile(state), instance)
|
|
1901
|
+
Renderer(True, state.show_sensitive).data(
|
|
1902
|
+
{
|
|
1903
|
+
"instance": instance,
|
|
1904
|
+
"ok": execution.ok,
|
|
1905
|
+
"phase": execution.phase,
|
|
1906
|
+
"exit_code": execution.exit_code,
|
|
1907
|
+
"stdout": execution.stdout,
|
|
1908
|
+
"stderr": execution.stderr,
|
|
1909
|
+
"error": error,
|
|
1910
|
+
"credential_source": credential_source,
|
|
1911
|
+
}
|
|
1912
|
+
)
|
|
1913
|
+
raise typer.Exit(execution.exit_code)
|
|
1781
1914
|
if password and state.show_sensitive:
|
|
1782
1915
|
typer.echo(f"Password: {password}")
|
|
1783
1916
|
if password and auto_password:
|
|
@@ -1795,6 +1928,8 @@ def ssh(
|
|
|
1795
1928
|
err=True,
|
|
1796
1929
|
)
|
|
1797
1930
|
else:
|
|
1931
|
+
if exit_code == 255 and credential_source == "cache":
|
|
1932
|
+
SSHCredentialCache().delete(_ssh_cache_profile(state), instance)
|
|
1798
1933
|
raise typer.Exit(exit_code)
|
|
1799
1934
|
if password and not state.show_sensitive:
|
|
1800
1935
|
typer.echo(tr("Password hidden; rerun with --show-sensitive to display it."))
|
|
@@ -1806,7 +1941,10 @@ def ssh(
|
|
|
1806
1941
|
instance=instance,
|
|
1807
1942
|
)
|
|
1808
1943
|
)
|
|
1809
|
-
|
|
1944
|
+
exit_code = subprocess.call(argv)
|
|
1945
|
+
if exit_code == 255 and credential_source == "cache":
|
|
1946
|
+
SSHCredentialCache().delete(_ssh_cache_profile(state), instance)
|
|
1947
|
+
raise typer.Exit(exit_code)
|
|
1810
1948
|
|
|
1811
1949
|
|
|
1812
1950
|
@app.command("scp", help="Copy a local file or directory to an instance.")
|
|
@@ -93,6 +93,15 @@ ZH_TRANSLATIONS: Dict[str, str] = {
|
|
|
93
93
|
"Month, Day, Dynamic or Postpay.": "目标计费方式:Month、Day、Dynamic 或 Postpay。",
|
|
94
94
|
"Print instead of executing SSH.": "只显示 SSH 命令,不执行连接。",
|
|
95
95
|
"Automatically enter the password returned by the API.": "自动填写 API 返回的登录密码。",
|
|
96
|
+
"Wait for the instance to be running before connecting.": "连接前等待实例进入运行状态。",
|
|
97
|
+
"Maximum SSH connection time in seconds.": "SSH 建立连接的最长时间(秒)。",
|
|
98
|
+
"Cache SSH connection data to avoid repeated instance queries.": (
|
|
99
|
+
"缓存 SSH 连接信息,避免重复查询实例。"
|
|
100
|
+
),
|
|
101
|
+
"SSH connection cache lifetime in seconds.": "SSH 连接缓存的有效期(秒)。",
|
|
102
|
+
"Refresh SSH connection data from the API before connecting.": (
|
|
103
|
+
"连接前从 API 强制刷新 SSH 连接信息。"
|
|
104
|
+
),
|
|
96
105
|
"Remote command and arguments; use -- before command options.": (
|
|
97
106
|
"要在实例中执行的命令及参数;命令包含选项时请先使用 -- 分隔。"
|
|
98
107
|
),
|
|
@@ -618,6 +627,9 @@ ZH_TRANSLATIONS: Dict[str, str] = {
|
|
|
618
627
|
"Config {path}: file {file_mode}, directory {directory_mode}": (
|
|
619
628
|
"配置 {path}:文件权限 {file_mode},目录权限 {directory_mode}"
|
|
620
629
|
),
|
|
630
|
+
"Config {path}: Windows ACLs apply; POSIX permission check skipped.": (
|
|
631
|
+
"配置 {path}:由 Windows ACL 管理访问权限,已跳过 POSIX 权限位检查"
|
|
632
|
+
),
|
|
621
633
|
"Using environment credentials": "正在使用环境变量凭证",
|
|
622
634
|
"Config file does not exist: {path}": "配置文件不存在:{path}",
|
|
623
635
|
"Connected; {count} availability zones returned.": "连接成功,返回 {count} 个可用区。",
|
|
@@ -65,6 +65,22 @@ def sanitized(value: Any, *, show_sensitive: bool = False) -> Any:
|
|
|
65
65
|
return value
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
def _write_json(payload: Dict[str, Any]) -> None:
|
|
69
|
+
"""Write one UTF-8 JSON document independently of the console code page."""
|
|
70
|
+
document = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
71
|
+
stream = sys.stdout
|
|
72
|
+
binary = getattr(stream, "buffer", None)
|
|
73
|
+
if binary is not None:
|
|
74
|
+
stream.flush()
|
|
75
|
+
binary.write(document.encode("utf-8"))
|
|
76
|
+
binary.flush()
|
|
77
|
+
return
|
|
78
|
+
# String-only test/application streams have no byte encoding contract. Escaping
|
|
79
|
+
# non-ASCII keeps this fallback valid UTF-8 under every ASCII-compatible encoding.
|
|
80
|
+
stream.write(json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n")
|
|
81
|
+
stream.flush()
|
|
82
|
+
|
|
83
|
+
|
|
68
84
|
class Renderer:
|
|
69
85
|
def __init__(self, json_output: bool, show_sensitive: bool = False) -> None:
|
|
70
86
|
self.json_output = json_output
|
|
@@ -80,7 +96,7 @@ class Renderer:
|
|
|
80
96
|
) -> None:
|
|
81
97
|
safe = sanitized(response, show_sensitive=self.show_sensitive)
|
|
82
98
|
if self.json_output:
|
|
83
|
-
|
|
99
|
+
_write_json(safe)
|
|
84
100
|
return
|
|
85
101
|
if rows is not None and columns:
|
|
86
102
|
self.table(rows, columns)
|
|
@@ -139,7 +155,7 @@ class Renderer:
|
|
|
139
155
|
details,
|
|
140
156
|
show_sensitive=self.show_sensitive,
|
|
141
157
|
)
|
|
142
|
-
|
|
158
|
+
_write_json(payload)
|
|
143
159
|
else:
|
|
144
160
|
Console(stderr=True).print(f"[red]{tr('Error')}:[/red] {message}")
|
|
145
161
|
|
|
@@ -7,6 +7,7 @@ import signal
|
|
|
7
7
|
import subprocess
|
|
8
8
|
import sys
|
|
9
9
|
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
10
11
|
from typing import Any, List, Optional
|
|
11
12
|
|
|
12
13
|
_ASKPASS_PASSWORD_FILE_ENV = "COMPSHARE_INTERNAL_SSH_PASSWORD_FILE"
|
|
@@ -40,6 +41,25 @@ class PasswordAutomationUnavailable(RuntimeError):
|
|
|
40
41
|
"""Raised when the current terminal cannot safely automate an SSH password."""
|
|
41
42
|
|
|
42
43
|
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class RemoteExecutionResult:
|
|
46
|
+
"""Captured output and an Agent-friendly classification of an SSH execution."""
|
|
47
|
+
|
|
48
|
+
exit_code: int
|
|
49
|
+
stdout: str
|
|
50
|
+
stderr: str
|
|
51
|
+
phase: str
|
|
52
|
+
error_code: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def ok(self) -> bool:
|
|
56
|
+
return self.exit_code == 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_windows() -> bool:
|
|
60
|
+
return os.name == "nt"
|
|
61
|
+
|
|
62
|
+
|
|
43
63
|
def _password_authentication(argv: List[str], *, command_mode: bool = False) -> List[str]:
|
|
44
64
|
executable = os.path.basename(argv[0]).casefold()
|
|
45
65
|
if executable not in {"ssh", "ssh.exe"}:
|
|
@@ -133,7 +153,7 @@ def scp_upload_command(
|
|
|
133
153
|
|
|
134
154
|
|
|
135
155
|
def _askpass_executable() -> str:
|
|
136
|
-
name = "compshare-ssh-askpass.exe" if
|
|
156
|
+
name = "compshare-ssh-askpass.exe" if _is_windows() else "compshare-ssh-askpass"
|
|
137
157
|
sibling = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), name)
|
|
138
158
|
if os.path.isfile(sibling) and os.access(sibling, os.X_OK):
|
|
139
159
|
return sibling
|
|
@@ -167,13 +187,70 @@ def execute_with_password(argv: List[str], password: str) -> int:
|
|
|
167
187
|
return _run_with_askpass(command, password)
|
|
168
188
|
|
|
169
189
|
|
|
190
|
+
def execute_captured_with_password(argv: List[str], password: str) -> RemoteExecutionResult:
|
|
191
|
+
"""Run and capture a non-interactive SSH command without exposing its password."""
|
|
192
|
+
command = _password_authentication(argv, command_mode=True)
|
|
193
|
+
completed = _run_with_askpass(command, password, capture=True)
|
|
194
|
+
assert isinstance(completed, subprocess.CompletedProcess)
|
|
195
|
+
return remote_execution_result(completed.returncode, completed.stdout, completed.stderr)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def execute_captured(argv: List[str]) -> RemoteExecutionResult:
|
|
199
|
+
"""Run and capture a non-interactive SSH command using normal OpenSSH auth."""
|
|
200
|
+
executable = os.path.basename(argv[0]).casefold()
|
|
201
|
+
if executable not in {"ssh", "ssh.exe"}:
|
|
202
|
+
raise PasswordAutomationUnavailable
|
|
203
|
+
command = [
|
|
204
|
+
argv[0],
|
|
205
|
+
"-o",
|
|
206
|
+
"StrictHostKeyChecking=accept-new",
|
|
207
|
+
"-n",
|
|
208
|
+
"-T",
|
|
209
|
+
*argv[1:],
|
|
210
|
+
]
|
|
211
|
+
completed = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
212
|
+
return remote_execution_result(completed.returncode, completed.stdout, completed.stderr)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def remote_execution_result(exit_code: int, stdout: str, stderr: str) -> RemoteExecutionResult:
|
|
216
|
+
"""Classify common OpenSSH failures while preserving the original diagnostics."""
|
|
217
|
+
if exit_code == 0:
|
|
218
|
+
return RemoteExecutionResult(exit_code, stdout, stderr, "completed")
|
|
219
|
+
|
|
220
|
+
diagnostic = stderr.casefold()
|
|
221
|
+
patterns = (
|
|
222
|
+
("could not resolve hostname", "connection", "dns_resolution_failed"),
|
|
223
|
+
("name or service not known", "connection", "dns_resolution_failed"),
|
|
224
|
+
("connection timed out", "connection", "connection_timeout"),
|
|
225
|
+
("operation timed out", "connection", "connection_timeout"),
|
|
226
|
+
("no route to host", "connection", "network_unreachable"),
|
|
227
|
+
("network is unreachable", "connection", "network_unreachable"),
|
|
228
|
+
("connection refused", "connection", "connection_refused"),
|
|
229
|
+
("host key verification failed", "connection", "host_key_verification_failed"),
|
|
230
|
+
("permission denied", "authentication", "authentication_failed"),
|
|
231
|
+
("authentication failed", "authentication", "authentication_failed"),
|
|
232
|
+
("too many authentication failures", "authentication", "authentication_failed"),
|
|
233
|
+
)
|
|
234
|
+
for marker, phase, error_code in patterns:
|
|
235
|
+
if marker in diagnostic:
|
|
236
|
+
return RemoteExecutionResult(exit_code, stdout, stderr, phase, error_code)
|
|
237
|
+
if exit_code == 255:
|
|
238
|
+
return RemoteExecutionResult(exit_code, stdout, stderr, "ssh", "ssh_failed")
|
|
239
|
+
return RemoteExecutionResult(exit_code, stdout, stderr, "remote_command", "remote_exit_nonzero")
|
|
240
|
+
|
|
241
|
+
|
|
170
242
|
def copy_with_password(argv: List[str], password: str) -> int:
|
|
171
243
|
"""Run an SCP upload using the API-provided password."""
|
|
172
244
|
command = _scp_password_authentication(argv)
|
|
173
245
|
return _run_with_askpass(command, password)
|
|
174
246
|
|
|
175
247
|
|
|
176
|
-
def _run_with_askpass(
|
|
248
|
+
def _run_with_askpass(
|
|
249
|
+
command: List[str],
|
|
250
|
+
password: str,
|
|
251
|
+
*,
|
|
252
|
+
capture: bool = False,
|
|
253
|
+
) -> Any:
|
|
177
254
|
with tempfile.TemporaryDirectory(prefix="compshare-ssh-") as temporary_directory:
|
|
178
255
|
password_file = os.path.join(temporary_directory, "password")
|
|
179
256
|
descriptor = os.open(password_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
@@ -188,12 +265,22 @@ def _run_with_askpass(command: List[str], password: str) -> int:
|
|
|
188
265
|
}
|
|
189
266
|
)
|
|
190
267
|
environment.setdefault("DISPLAY", "compshare-ssh")
|
|
268
|
+
if capture:
|
|
269
|
+
return subprocess.run(
|
|
270
|
+
command,
|
|
271
|
+
env=environment,
|
|
272
|
+
capture_output=True,
|
|
273
|
+
text=True,
|
|
274
|
+
check=False,
|
|
275
|
+
)
|
|
191
276
|
return subprocess.call(command, env=environment)
|
|
192
277
|
|
|
193
278
|
|
|
194
279
|
def connect_with_password(argv: List[str], password: str) -> int:
|
|
195
280
|
"""Run SSH interactively and answer its first password prompt."""
|
|
196
|
-
if
|
|
281
|
+
if _is_windows():
|
|
282
|
+
return _run_with_askpass(_password_authentication(argv), password)
|
|
283
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
197
284
|
raise PasswordAutomationUnavailable
|
|
198
285
|
|
|
199
286
|
try:
|