compshare-cli 0.3.1__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.1 → compshare_cli-0.3.2}/PKG-INFO +10 -1
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/README.md +9 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/pyproject.toml +1 -1
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/__init__.py +1 -1
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/instance.py +143 -5
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/i18n.py +9 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/output.py +18 -2
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/ssh.py +82 -1
- compshare_cli-0.3.2/src/compshare_cli/ssh_cache.py +218 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/PKG-INFO +10 -1
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/SOURCES.txt +3 -1
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_cli.py +120 -1
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_output.py +13 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_ssh.py +25 -0
- compshare_cli-0.3.2/tests/test_ssh_cache.py +47 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/LICENSE +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/setup.cfg +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/__main__.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/actions.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/api.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/cli.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/__init__.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/ask.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/common.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/doctor.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/feedback.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/image.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/storage.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/team.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/config.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/errors.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/insights.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/location.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/parsing.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/qa.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/runtime.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/sdk.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/dependency_links.txt +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/entry_points.txt +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/requires.txt +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/top_level.txt +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_actions.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_api.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_config.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_feedback.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_location.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_parsing.py +0 -0
- {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_qa.py +0 -0
- {compshare_cli-0.3.1 → 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
|
|
@@ -116,6 +116,11 @@ compshare instance ssh INSTANCE_ID --no-auto-password
|
|
|
116
116
|
# 自动登录后执行远程命令;远程参数用 -- 与 CLI 选项分隔
|
|
117
117
|
compshare instance ssh INSTANCE_ID -- nvidia-smi --query-gpu=name
|
|
118
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
|
|
119
124
|
# 上传本地文件或目录,目录会自动递归复制
|
|
120
125
|
compshare instance scp INSTANCE_ID ./model.bin /workspace/model.bin
|
|
121
126
|
compshare instance scp INSTANCE_ID ./dataset /workspace/dataset
|
|
@@ -230,6 +235,10 @@ compshare lang # 查看当前语言
|
|
|
230
235
|
- `instance ssh` 在支持的交互式终端中通过伪终端自动填写登录密码,不会打印密码或将其放入进程参数。
|
|
231
236
|
- 自动登录会接受首次出现的 SSH 主机密钥;已记录主机的密钥发生变化时仍会拒绝连接。
|
|
232
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` 也会默认等待实例运行后再连接。
|
|
233
242
|
- `instance scp INSTANCE_ID LOCAL_PATH REMOTE_PATH` 可自动认证并上传本地文件或目录。
|
|
234
243
|
- `--show-sensitive` 会恢复这些字段的原始值;请勿在共享终端、CI 日志或 Agent 会话中使用。
|
|
235
244
|
- 删除、关机、重启、重装和改配等操作默认要求确认。
|
|
@@ -91,6 +91,11 @@ compshare instance ssh INSTANCE_ID --no-auto-password
|
|
|
91
91
|
# 自动登录后执行远程命令;远程参数用 -- 与 CLI 选项分隔
|
|
92
92
|
compshare instance ssh INSTANCE_ID -- nvidia-smi --query-gpu=name
|
|
93
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
|
|
94
99
|
# 上传本地文件或目录,目录会自动递归复制
|
|
95
100
|
compshare instance scp INSTANCE_ID ./model.bin /workspace/model.bin
|
|
96
101
|
compshare instance scp INSTANCE_ID ./dataset /workspace/dataset
|
|
@@ -205,6 +210,10 @@ compshare lang # 查看当前语言
|
|
|
205
210
|
- `instance ssh` 在支持的交互式终端中通过伪终端自动填写登录密码,不会打印密码或将其放入进程参数。
|
|
206
211
|
- 自动登录会接受首次出现的 SSH 主机密钥;已记录主机的密钥发生变化时仍会拒绝连接。
|
|
207
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` 也会默认等待实例运行后再连接。
|
|
208
217
|
- `instance scp INSTANCE_ID LOCAL_PATH REMOTE_PATH` 可自动认证并上传本地文件或目录。
|
|
209
218
|
- `--show-sensitive` 会恢复这些字段的原始值;请勿在共享终端、CI 日志或 Agent 会话中使用。
|
|
210
219
|
- 删除、关机、重启、重装和改配等操作默认要求确认。
|
|
@@ -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
|
),
|
|
@@ -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,21 @@ 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
|
+
|
|
43
59
|
def _is_windows() -> bool:
|
|
44
60
|
return os.name == "nt"
|
|
45
61
|
|
|
@@ -171,13 +187,70 @@ def execute_with_password(argv: List[str], password: str) -> int:
|
|
|
171
187
|
return _run_with_askpass(command, password)
|
|
172
188
|
|
|
173
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
|
+
|
|
174
242
|
def copy_with_password(argv: List[str], password: str) -> int:
|
|
175
243
|
"""Run an SCP upload using the API-provided password."""
|
|
176
244
|
command = _scp_password_authentication(argv)
|
|
177
245
|
return _run_with_askpass(command, password)
|
|
178
246
|
|
|
179
247
|
|
|
180
|
-
def _run_with_askpass(
|
|
248
|
+
def _run_with_askpass(
|
|
249
|
+
command: List[str],
|
|
250
|
+
password: str,
|
|
251
|
+
*,
|
|
252
|
+
capture: bool = False,
|
|
253
|
+
) -> Any:
|
|
181
254
|
with tempfile.TemporaryDirectory(prefix="compshare-ssh-") as temporary_directory:
|
|
182
255
|
password_file = os.path.join(temporary_directory, "password")
|
|
183
256
|
descriptor = os.open(password_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
@@ -192,6 +265,14 @@ def _run_with_askpass(command: List[str], password: str) -> int:
|
|
|
192
265
|
}
|
|
193
266
|
)
|
|
194
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
|
+
)
|
|
195
276
|
return subprocess.call(command, env=environment)
|
|
196
277
|
|
|
197
278
|
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
from base64 import b64decode, b64encode
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from compshare_cli.config import config_path
|
|
13
|
+
|
|
14
|
+
CACHE_VERSION = 1
|
|
15
|
+
DEFAULT_TTL = 3600
|
|
16
|
+
_CACHED_HOST_FIELDS = {
|
|
17
|
+
"Password",
|
|
18
|
+
"Region",
|
|
19
|
+
"SshLoginCommand",
|
|
20
|
+
"State",
|
|
21
|
+
"UHostId",
|
|
22
|
+
"Zone",
|
|
23
|
+
}
|
|
24
|
+
_DPAPI_PREFIX = "dpapi:"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _protect_password(password: str) -> Optional[str]:
|
|
28
|
+
if os.name != "nt":
|
|
29
|
+
return password
|
|
30
|
+
try:
|
|
31
|
+
import ctypes
|
|
32
|
+
from ctypes import wintypes
|
|
33
|
+
|
|
34
|
+
class DataBlob(ctypes.Structure):
|
|
35
|
+
_fields_ = [("size", wintypes.DWORD), ("data", ctypes.POINTER(ctypes.c_byte))]
|
|
36
|
+
|
|
37
|
+
raw = password.encode("utf-8")
|
|
38
|
+
buffer = ctypes.create_string_buffer(raw)
|
|
39
|
+
source = DataBlob(len(raw), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte)))
|
|
40
|
+
protected = DataBlob()
|
|
41
|
+
if not ctypes.windll.crypt32.CryptProtectData(
|
|
42
|
+
ctypes.byref(source),
|
|
43
|
+
"CompShare SSH cache",
|
|
44
|
+
None,
|
|
45
|
+
None,
|
|
46
|
+
None,
|
|
47
|
+
0x1, # CRYPTPROTECT_UI_FORBIDDEN
|
|
48
|
+
ctypes.byref(protected),
|
|
49
|
+
):
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
encrypted = ctypes.string_at(protected.data, protected.size)
|
|
53
|
+
finally:
|
|
54
|
+
ctypes.windll.kernel32.LocalFree(protected.data)
|
|
55
|
+
return _DPAPI_PREFIX + b64encode(encrypted).decode("ascii")
|
|
56
|
+
except (AttributeError, OSError, ValueError):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _unprotect_password(password: str) -> Optional[str]:
|
|
61
|
+
if os.name != "nt":
|
|
62
|
+
return password
|
|
63
|
+
if not password.startswith(_DPAPI_PREFIX):
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
import ctypes
|
|
67
|
+
from ctypes import wintypes
|
|
68
|
+
|
|
69
|
+
class DataBlob(ctypes.Structure):
|
|
70
|
+
_fields_ = [("size", wintypes.DWORD), ("data", ctypes.POINTER(ctypes.c_byte))]
|
|
71
|
+
|
|
72
|
+
raw = b64decode(password[len(_DPAPI_PREFIX) :], validate=True)
|
|
73
|
+
buffer = ctypes.create_string_buffer(raw)
|
|
74
|
+
source = DataBlob(len(raw), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte)))
|
|
75
|
+
clear = DataBlob()
|
|
76
|
+
if not ctypes.windll.crypt32.CryptUnprotectData(
|
|
77
|
+
ctypes.byref(source),
|
|
78
|
+
None,
|
|
79
|
+
None,
|
|
80
|
+
None,
|
|
81
|
+
None,
|
|
82
|
+
0x1, # CRYPTPROTECT_UI_FORBIDDEN
|
|
83
|
+
ctypes.byref(clear),
|
|
84
|
+
):
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
decrypted = ctypes.string_at(clear.data, clear.size)
|
|
88
|
+
finally:
|
|
89
|
+
ctypes.windll.kernel32.LocalFree(clear.data)
|
|
90
|
+
return decrypted.decode("utf-8")
|
|
91
|
+
except (AttributeError, OSError, UnicodeDecodeError, ValueError):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def ssh_cache_path() -> Path:
|
|
96
|
+
override = os.environ.get("COMPSHARE_SSH_CACHE_FILE")
|
|
97
|
+
if override:
|
|
98
|
+
return Path(override).expanduser()
|
|
99
|
+
return config_path().with_name("ssh-cache.json")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SSHCredentialCache:
|
|
103
|
+
"""Short-lived, profile-scoped cache for API-provided SSH connection data."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, path: Optional[Path] = None) -> None:
|
|
106
|
+
self.path = path or ssh_cache_path()
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _key(profile: str, instance: str) -> str:
|
|
110
|
+
return f"{profile}\0{instance}"
|
|
111
|
+
|
|
112
|
+
def get(
|
|
113
|
+
self,
|
|
114
|
+
profile: str,
|
|
115
|
+
instance: str,
|
|
116
|
+
*,
|
|
117
|
+
ttl: int = DEFAULT_TTL,
|
|
118
|
+
now: Optional[float] = None,
|
|
119
|
+
) -> Optional[Dict[str, Any]]:
|
|
120
|
+
if ttl <= 0:
|
|
121
|
+
return None
|
|
122
|
+
data = self._read()
|
|
123
|
+
raw = data.get("entries", {}).get(self._key(profile, instance))
|
|
124
|
+
if not isinstance(raw, dict):
|
|
125
|
+
return None
|
|
126
|
+
cached_at = raw.get("cached_at")
|
|
127
|
+
host = raw.get("host")
|
|
128
|
+
current = time.time() if now is None else now
|
|
129
|
+
if not isinstance(cached_at, (int, float)) or current - cached_at > ttl:
|
|
130
|
+
return None
|
|
131
|
+
if not isinstance(host, dict):
|
|
132
|
+
return None
|
|
133
|
+
if not host.get("SshLoginCommand"):
|
|
134
|
+
return None
|
|
135
|
+
result = dict(host)
|
|
136
|
+
password = result.get("Password")
|
|
137
|
+
if isinstance(password, str):
|
|
138
|
+
clear = _unprotect_password(password)
|
|
139
|
+
if clear is None:
|
|
140
|
+
return None
|
|
141
|
+
result["Password"] = clear
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
def put(
|
|
145
|
+
self,
|
|
146
|
+
profile: str,
|
|
147
|
+
instance: str,
|
|
148
|
+
host: Dict[str, Any],
|
|
149
|
+
*,
|
|
150
|
+
now: Optional[float] = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
cached_host = {key: host[key] for key in _CACHED_HOST_FIELDS if host.get(key) is not None}
|
|
153
|
+
if not cached_host.get("SshLoginCommand"):
|
|
154
|
+
return
|
|
155
|
+
password = cached_host.get("Password")
|
|
156
|
+
if isinstance(password, str):
|
|
157
|
+
protected = _protect_password(password)
|
|
158
|
+
if protected is None:
|
|
159
|
+
return
|
|
160
|
+
cached_host["Password"] = protected
|
|
161
|
+
data = self._read()
|
|
162
|
+
entries = data.setdefault("entries", {})
|
|
163
|
+
entries[self._key(profile, instance)] = {
|
|
164
|
+
"cached_at": time.time() if now is None else now,
|
|
165
|
+
"host": cached_host,
|
|
166
|
+
}
|
|
167
|
+
self._write(data)
|
|
168
|
+
|
|
169
|
+
def delete(self, profile: str, instance: str) -> None:
|
|
170
|
+
data = self._read()
|
|
171
|
+
entries = data.get("entries", {})
|
|
172
|
+
if not isinstance(entries, dict) or entries.pop(self._key(profile, instance), None) is None:
|
|
173
|
+
return
|
|
174
|
+
self._write(data)
|
|
175
|
+
|
|
176
|
+
def _read(self) -> Dict[str, Any]:
|
|
177
|
+
if not self.path.exists():
|
|
178
|
+
return {"version": CACHE_VERSION, "entries": {}}
|
|
179
|
+
try:
|
|
180
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
181
|
+
except (OSError, json.JSONDecodeError):
|
|
182
|
+
return {"version": CACHE_VERSION, "entries": {}}
|
|
183
|
+
if (
|
|
184
|
+
not isinstance(data, dict)
|
|
185
|
+
or data.get("version") != CACHE_VERSION
|
|
186
|
+
or not isinstance(data.get("entries"), dict)
|
|
187
|
+
):
|
|
188
|
+
return {"version": CACHE_VERSION, "entries": {}}
|
|
189
|
+
return data
|
|
190
|
+
|
|
191
|
+
def _write(self, data: Dict[str, Any]) -> None:
|
|
192
|
+
temporary: Optional[Path] = None
|
|
193
|
+
try:
|
|
194
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
os.chmod(self.path.parent, stat.S_IRWXU)
|
|
196
|
+
with tempfile.NamedTemporaryFile(
|
|
197
|
+
mode="w",
|
|
198
|
+
encoding="utf-8",
|
|
199
|
+
dir=self.path.parent,
|
|
200
|
+
prefix=f".{self.path.name}.",
|
|
201
|
+
suffix=".tmp",
|
|
202
|
+
delete=False,
|
|
203
|
+
) as handle:
|
|
204
|
+
temporary = Path(handle.name)
|
|
205
|
+
os.chmod(temporary, stat.S_IRUSR | stat.S_IWUSR)
|
|
206
|
+
handle.write(json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
207
|
+
handle.flush()
|
|
208
|
+
os.fsync(handle.fileno())
|
|
209
|
+
temporary.replace(self.path)
|
|
210
|
+
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
|
211
|
+
except OSError:
|
|
212
|
+
if temporary is not None:
|
|
213
|
+
try:
|
|
214
|
+
temporary.unlink(missing_ok=True)
|
|
215
|
+
except OSError:
|
|
216
|
+
pass
|
|
217
|
+
# Caching is an optimization and must never prevent an SSH connection.
|
|
218
|
+
return
|
|
@@ -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
|
|
@@ -116,6 +116,11 @@ compshare instance ssh INSTANCE_ID --no-auto-password
|
|
|
116
116
|
# 自动登录后执行远程命令;远程参数用 -- 与 CLI 选项分隔
|
|
117
117
|
compshare instance ssh INSTANCE_ID -- nvidia-smi --query-gpu=name
|
|
118
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
|
|
119
124
|
# 上传本地文件或目录,目录会自动递归复制
|
|
120
125
|
compshare instance scp INSTANCE_ID ./model.bin /workspace/model.bin
|
|
121
126
|
compshare instance scp INSTANCE_ID ./dataset /workspace/dataset
|
|
@@ -230,6 +235,10 @@ compshare lang # 查看当前语言
|
|
|
230
235
|
- `instance ssh` 在支持的交互式终端中通过伪终端自动填写登录密码,不会打印密码或将其放入进程参数。
|
|
231
236
|
- 自动登录会接受首次出现的 SSH 主机密钥;已记录主机的密钥发生变化时仍会拒绝连接。
|
|
232
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` 也会默认等待实例运行后再连接。
|
|
233
242
|
- `instance scp INSTANCE_ID LOCAL_PATH REMOTE_PATH` 可自动认证并上传本地文件或目录。
|
|
234
243
|
- `--show-sensitive` 会恢复这些字段的原始值;请勿在共享终端、CI 日志或 Agent 会话中使用。
|
|
235
244
|
- 删除、关机、重启、重装和改配等操作默认要求确认。
|
|
@@ -17,6 +17,7 @@ src/compshare_cli/qa.py
|
|
|
17
17
|
src/compshare_cli/runtime.py
|
|
18
18
|
src/compshare_cli/sdk.py
|
|
19
19
|
src/compshare_cli/ssh.py
|
|
20
|
+
src/compshare_cli/ssh_cache.py
|
|
20
21
|
src/compshare_cli.egg-info/PKG-INFO
|
|
21
22
|
src/compshare_cli.egg-info/SOURCES.txt
|
|
22
23
|
src/compshare_cli.egg-info/dependency_links.txt
|
|
@@ -42,4 +43,5 @@ tests/test_output.py
|
|
|
42
43
|
tests/test_parsing.py
|
|
43
44
|
tests/test_qa.py
|
|
44
45
|
tests/test_sdk.py
|
|
45
|
-
tests/test_ssh.py
|
|
46
|
+
tests/test_ssh.py
|
|
47
|
+
tests/test_ssh_cache.py
|
|
@@ -9,10 +9,18 @@ from compshare_cli.commands import doctor as doctor_module
|
|
|
9
9
|
from compshare_cli.commands import image as image_module
|
|
10
10
|
from compshare_cli.commands import instance, team
|
|
11
11
|
from compshare_cli.i18n import localize_command
|
|
12
|
+
from compshare_cli.ssh import RemoteExecutionResult
|
|
12
13
|
|
|
13
14
|
runner = CliRunner()
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
@pytest.fixture(autouse=True)
|
|
18
|
+
def isolate_ssh_cache(monkeypatch, tmp_path) -> None:
|
|
19
|
+
monkeypatch.setenv("COMPSHARE_SSH_CACHE_FILE", str(tmp_path / "ssh-cache.json"))
|
|
20
|
+
monkeypatch.setenv("COMPSHARE_PUBLIC_KEY", "test-public")
|
|
21
|
+
monkeypatch.setenv("COMPSHARE_PRIVATE_KEY", "test-private")
|
|
22
|
+
|
|
23
|
+
|
|
16
24
|
@pytest.mark.parametrize(
|
|
17
25
|
"args",
|
|
18
26
|
[
|
|
@@ -375,7 +383,7 @@ def test_create_interactive_wizard_resolves_all_required_parameters(monkeypatch)
|
|
|
375
383
|
)
|
|
376
384
|
result = runner.invoke(
|
|
377
385
|
cli.app,
|
|
378
|
-
["instance", "create", "--yes"],
|
|
386
|
+
["instance", "create", "--yes", "--no-wait"],
|
|
379
387
|
input="1\n1\n\n\n",
|
|
380
388
|
env={
|
|
381
389
|
"COMPSHARE_PUBLIC_KEY": "public",
|
|
@@ -467,6 +475,14 @@ def test_create_explicit_json_mode_skips_discovery(monkeypatch) -> None:
|
|
|
467
475
|
return {"RetCode": 0, "UHostIds": ["uhost-test"]}
|
|
468
476
|
|
|
469
477
|
monkeypatch.setattr(instance, "call", fake_call)
|
|
478
|
+
waits = []
|
|
479
|
+
monkeypatch.setattr(
|
|
480
|
+
instance,
|
|
481
|
+
"_wait_for_instance",
|
|
482
|
+
lambda state, value, **kwargs: (
|
|
483
|
+
waits.append((value, kwargs)) or {"UHostSet": [{"UHostId": value, "State": "Running"}]}
|
|
484
|
+
),
|
|
485
|
+
)
|
|
470
486
|
result = runner.invoke(
|
|
471
487
|
cli.app,
|
|
472
488
|
[
|
|
@@ -502,6 +518,7 @@ def test_create_explicit_json_mode_skips_discovery(monkeypatch) -> None:
|
|
|
502
518
|
"CreateCompShareInstance",
|
|
503
519
|
]
|
|
504
520
|
assert json.loads(result.stdout)["selection"]["ChargeType"] == "Postpay"
|
|
521
|
+
assert waits == [("uhost-test", {"region": "cn-wlcb", "desired": {"Running"}, "timeout": 600})]
|
|
505
522
|
|
|
506
523
|
|
|
507
524
|
def test_create_dry_run_never_calls_create(monkeypatch) -> None:
|
|
@@ -776,6 +793,7 @@ def test_ssh_print_redacts_sensitive_values_by_default(monkeypatch) -> None:
|
|
|
776
793
|
"instance": "uhost-1",
|
|
777
794
|
"command": "***",
|
|
778
795
|
"password": "***",
|
|
796
|
+
"credential_source": "api",
|
|
779
797
|
}
|
|
780
798
|
|
|
781
799
|
|
|
@@ -804,9 +822,41 @@ def test_ssh_print_includes_sensitive_values_only_with_global_flag(monkeypatch)
|
|
|
804
822
|
"instance": "uhost-1",
|
|
805
823
|
"command": "ssh root@example.invalid",
|
|
806
824
|
"password": "instance-secret",
|
|
825
|
+
"credential_source": "api",
|
|
807
826
|
}
|
|
808
827
|
|
|
809
828
|
|
|
829
|
+
def test_ssh_reuses_cached_connection_data_without_describing_again(monkeypatch) -> None:
|
|
830
|
+
descriptions = []
|
|
831
|
+
|
|
832
|
+
def locate(state, value):
|
|
833
|
+
descriptions.append(value)
|
|
834
|
+
return (
|
|
835
|
+
"cn-wlcb",
|
|
836
|
+
"cn-wlcb-01",
|
|
837
|
+
{
|
|
838
|
+
"UHostId": value,
|
|
839
|
+
"Region": "cn-wlcb",
|
|
840
|
+
"Zone": "cn-wlcb-01",
|
|
841
|
+
"State": "Running",
|
|
842
|
+
"SshLoginCommand": "ssh root@example.invalid",
|
|
843
|
+
"Password": "instance-secret",
|
|
844
|
+
},
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
monkeypatch.setattr(instance, "locate_instance", locate)
|
|
848
|
+
args = ["--json", "instance", "ssh", "uhost-1", "--print"]
|
|
849
|
+
|
|
850
|
+
first = runner.invoke(cli.app, args)
|
|
851
|
+
second = runner.invoke(cli.app, args)
|
|
852
|
+
|
|
853
|
+
assert first.exit_code == 0, first.output
|
|
854
|
+
assert second.exit_code == 0, second.output
|
|
855
|
+
assert json.loads(first.stdout)["credential_source"] == "api"
|
|
856
|
+
assert json.loads(second.stdout)["credential_source"] == "cache"
|
|
857
|
+
assert descriptions == ["uhost-1"]
|
|
858
|
+
|
|
859
|
+
|
|
810
860
|
def test_ssh_automatically_enters_hidden_password_by_default(monkeypatch) -> None:
|
|
811
861
|
monkeypatch.setattr(
|
|
812
862
|
instance,
|
|
@@ -992,6 +1042,8 @@ def test_ssh_executes_remote_command_with_password_and_returns_its_status(monkey
|
|
|
992
1042
|
(
|
|
993
1043
|
[
|
|
994
1044
|
"ssh",
|
|
1045
|
+
"-o",
|
|
1046
|
+
"ConnectTimeout=30",
|
|
995
1047
|
"-p",
|
|
996
1048
|
"2222",
|
|
997
1049
|
"root@example.invalid",
|
|
@@ -1003,6 +1055,72 @@ def test_ssh_executes_remote_command_with_password_and_returns_its_status(monkey
|
|
|
1003
1055
|
]
|
|
1004
1056
|
|
|
1005
1057
|
|
|
1058
|
+
def test_ssh_json_executes_and_returns_structured_connection_error(monkeypatch) -> None:
|
|
1059
|
+
monkeypatch.setattr(
|
|
1060
|
+
instance,
|
|
1061
|
+
"locate_instance",
|
|
1062
|
+
lambda state, value: (
|
|
1063
|
+
"cn-wlcb",
|
|
1064
|
+
"cn-wlcb-01",
|
|
1065
|
+
{
|
|
1066
|
+
"UHostId": value,
|
|
1067
|
+
"State": "Running",
|
|
1068
|
+
"SshLoginCommand": "ssh root@example.invalid",
|
|
1069
|
+
"Password": "instance-secret",
|
|
1070
|
+
},
|
|
1071
|
+
),
|
|
1072
|
+
)
|
|
1073
|
+
executions = []
|
|
1074
|
+
monkeypatch.setattr(
|
|
1075
|
+
instance,
|
|
1076
|
+
"execute_captured_with_password",
|
|
1077
|
+
lambda argv, password: (
|
|
1078
|
+
executions.append((argv, password))
|
|
1079
|
+
or RemoteExecutionResult(
|
|
1080
|
+
255,
|
|
1081
|
+
"",
|
|
1082
|
+
"ssh: connect to host example.invalid port 22: Connection timed out\n",
|
|
1083
|
+
"connection",
|
|
1084
|
+
"connection_timeout",
|
|
1085
|
+
)
|
|
1086
|
+
),
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
result = runner.invoke(
|
|
1090
|
+
cli.app,
|
|
1091
|
+
["--json", "instance", "ssh", "uhost-1", "--", "hostname"],
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
assert result.exit_code == 255, result.output
|
|
1095
|
+
payload = json.loads(result.stdout)
|
|
1096
|
+
assert payload == {
|
|
1097
|
+
"instance": "uhost-1",
|
|
1098
|
+
"ok": False,
|
|
1099
|
+
"phase": "connection",
|
|
1100
|
+
"exit_code": 255,
|
|
1101
|
+
"stdout": "",
|
|
1102
|
+
"stderr": "ssh: connect to host example.invalid port 22: Connection timed out\n",
|
|
1103
|
+
"error": {
|
|
1104
|
+
"phase": "connection",
|
|
1105
|
+
"code": "connection_timeout",
|
|
1106
|
+
"message": "ssh: connect to host example.invalid port 22: Connection timed out",
|
|
1107
|
+
},
|
|
1108
|
+
"credential_source": "api",
|
|
1109
|
+
}
|
|
1110
|
+
assert executions == [
|
|
1111
|
+
(
|
|
1112
|
+
[
|
|
1113
|
+
"ssh",
|
|
1114
|
+
"-o",
|
|
1115
|
+
"ConnectTimeout=30",
|
|
1116
|
+
"root@example.invalid",
|
|
1117
|
+
"hostname",
|
|
1118
|
+
],
|
|
1119
|
+
"instance-secret",
|
|
1120
|
+
)
|
|
1121
|
+
]
|
|
1122
|
+
|
|
1123
|
+
|
|
1006
1124
|
def test_ssh_print_includes_quoted_remote_command_when_sensitive(monkeypatch) -> None:
|
|
1007
1125
|
monkeypatch.setattr(
|
|
1008
1126
|
instance,
|
|
@@ -1038,6 +1156,7 @@ def test_ssh_print_includes_quoted_remote_command_when_sensitive(monkeypatch) ->
|
|
|
1038
1156
|
"instance": "uhost-1",
|
|
1039
1157
|
"command": "ssh root@example.invalid 'cd /workspace && python train.py'",
|
|
1040
1158
|
"password": "instance-secret",
|
|
1159
|
+
"credential_source": "api",
|
|
1041
1160
|
}
|
|
1042
1161
|
|
|
1043
1162
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
|
+
from io import BytesIO, TextIOWrapper
|
|
2
3
|
|
|
4
|
+
from compshare_cli import output
|
|
3
5
|
from compshare_cli.output import Renderer, sanitized
|
|
4
6
|
|
|
5
7
|
|
|
@@ -31,6 +33,17 @@ def test_json_renderer_emits_one_compact_document(capsys) -> None:
|
|
|
31
33
|
assert json.loads(output) == {"RetCode": 0, "value": "中文"}
|
|
32
34
|
|
|
33
35
|
|
|
36
|
+
def test_json_renderer_writes_utf8_bytes_under_a_gbk_stdout(monkeypatch) -> None:
|
|
37
|
+
raw = BytesIO()
|
|
38
|
+
gbk_stdout = TextIOWrapper(raw, encoding="gbk")
|
|
39
|
+
monkeypatch.setattr(output.sys, "stdout", gbk_stdout)
|
|
40
|
+
|
|
41
|
+
Renderer(True).data({"ok": False, "error": "尚未配置 API 密钥。"})
|
|
42
|
+
|
|
43
|
+
document = raw.getvalue().decode("utf-8")
|
|
44
|
+
assert json.loads(document) == {"ok": False, "error": "尚未配置 API 密钥。"}
|
|
45
|
+
|
|
46
|
+
|
|
34
47
|
def test_table_accepts_no_rows(capsys) -> None:
|
|
35
48
|
Renderer(False).table(None, (("Id", "ID"),))
|
|
36
49
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
from types import SimpleNamespace
|
|
3
3
|
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
4
6
|
from compshare_cli import ssh
|
|
5
7
|
|
|
6
8
|
|
|
@@ -150,6 +152,29 @@ def test_execute_with_password_uses_askpass_without_password_in_argv(monkeypatch
|
|
|
150
152
|
assert ssh._ASKPASS_PASSWORD_FILE_ENV not in ssh.os.environ
|
|
151
153
|
|
|
152
154
|
|
|
155
|
+
@pytest.mark.parametrize(
|
|
156
|
+
("stderr", "phase", "error_code"),
|
|
157
|
+
[
|
|
158
|
+
(
|
|
159
|
+
"ssh: connect to host x port 22: Connection timed out",
|
|
160
|
+
"connection",
|
|
161
|
+
"connection_timeout",
|
|
162
|
+
),
|
|
163
|
+
(
|
|
164
|
+
"root@x: Permission denied (publickey,password).",
|
|
165
|
+
"authentication",
|
|
166
|
+
"authentication_failed",
|
|
167
|
+
),
|
|
168
|
+
("unclassified OpenSSH failure", "ssh", "ssh_failed"),
|
|
169
|
+
],
|
|
170
|
+
)
|
|
171
|
+
def test_remote_execution_result_classifies_ssh_failures(stderr, phase, error_code) -> None:
|
|
172
|
+
result = ssh.remote_execution_result(255, "", stderr)
|
|
173
|
+
|
|
174
|
+
assert result.phase == phase
|
|
175
|
+
assert result.error_code == error_code
|
|
176
|
+
|
|
177
|
+
|
|
153
178
|
def test_askpass_reads_and_removes_internal_password_file(monkeypatch, capsys, tmp_path) -> None:
|
|
154
179
|
password_file = tmp_path / "password"
|
|
155
180
|
password_file.write_text("instance-secret")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import stat
|
|
3
|
+
|
|
4
|
+
from compshare_cli.ssh_cache import SSHCredentialCache
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _host(password="secret"):
|
|
8
|
+
return {
|
|
9
|
+
"UHostId": "uhost-1",
|
|
10
|
+
"Region": "cn-wlcb",
|
|
11
|
+
"Zone": "cn-wlcb-01",
|
|
12
|
+
"State": "Running",
|
|
13
|
+
"SshLoginCommand": "ssh root@example.invalid",
|
|
14
|
+
"Password": password,
|
|
15
|
+
"Unrelated": "not-cached",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_ssh_cache_round_trip_is_profile_scoped_and_permission_restricted(tmp_path) -> None:
|
|
20
|
+
path = tmp_path / "ssh-cache.json"
|
|
21
|
+
cache = SSHCredentialCache(path)
|
|
22
|
+
cache.put("alpha", "uhost-1", _host(), now=100)
|
|
23
|
+
|
|
24
|
+
assert cache.get("alpha", "uhost-1", ttl=60, now=150) == {
|
|
25
|
+
key: value for key, value in _host().items() if key != "Unrelated"
|
|
26
|
+
}
|
|
27
|
+
assert cache.get("beta", "uhost-1", ttl=60, now=150) is None
|
|
28
|
+
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
29
|
+
saved = json.loads(path.read_text(encoding="utf-8"))
|
|
30
|
+
assert "Unrelated" not in next(iter(saved["entries"].values()))["host"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_ssh_cache_expires_and_can_be_deleted(tmp_path) -> None:
|
|
34
|
+
cache = SSHCredentialCache(tmp_path / "ssh-cache.json")
|
|
35
|
+
cache.put("default", "uhost-1", _host(), now=100)
|
|
36
|
+
|
|
37
|
+
assert cache.get("default", "uhost-1", ttl=60, now=161) is None
|
|
38
|
+
assert cache.get("default", "uhost-1", ttl=60, now=160) is not None
|
|
39
|
+
cache.delete("default", "uhost-1")
|
|
40
|
+
assert cache.get("default", "uhost-1", ttl=60, now=150) is None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_corrupt_ssh_cache_is_ignored(tmp_path) -> None:
|
|
44
|
+
path = tmp_path / "ssh-cache.json"
|
|
45
|
+
path.write_text("not json", encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
assert SSHCredentialCache(path).get("default", "uhost-1") is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|