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.
Files changed (49) hide show
  1. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/PKG-INFO +10 -1
  2. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/README.md +9 -0
  3. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/pyproject.toml +1 -1
  4. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/__init__.py +1 -1
  5. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/instance.py +143 -5
  6. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/i18n.py +9 -0
  7. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/output.py +18 -2
  8. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/ssh.py +82 -1
  9. compshare_cli-0.3.2/src/compshare_cli/ssh_cache.py +218 -0
  10. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/PKG-INFO +10 -1
  11. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/SOURCES.txt +3 -1
  12. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_cli.py +120 -1
  13. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_output.py +13 -0
  14. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_ssh.py +25 -0
  15. compshare_cli-0.3.2/tests/test_ssh_cache.py +47 -0
  16. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/LICENSE +0 -0
  17. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/setup.cfg +0 -0
  18. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/__main__.py +0 -0
  19. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/actions.py +0 -0
  20. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/api.py +0 -0
  21. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/cli.py +0 -0
  22. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/__init__.py +0 -0
  23. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/ask.py +0 -0
  24. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/common.py +0 -0
  25. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/doctor.py +0 -0
  26. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/feedback.py +0 -0
  27. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/image.py +0 -0
  28. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/storage.py +0 -0
  29. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/commands/team.py +0 -0
  30. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/config.py +0 -0
  31. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/errors.py +0 -0
  32. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/insights.py +0 -0
  33. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/location.py +0 -0
  34. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/parsing.py +0 -0
  35. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/qa.py +0 -0
  36. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/runtime.py +0 -0
  37. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli/sdk.py +0 -0
  38. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/dependency_links.txt +0 -0
  39. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/entry_points.txt +0 -0
  40. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/requires.txt +0 -0
  41. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/src/compshare_cli.egg-info/top_level.txt +0 -0
  42. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_actions.py +0 -0
  43. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_api.py +0 -0
  44. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_config.py +0 -0
  45. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_feedback.py +0 -0
  46. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_location.py +0 -0
  47. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_parsing.py +0 -0
  48. {compshare_cli-0.3.1 → compshare_cli-0.3.2}/tests/test_qa.py +0 -0
  49. {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.1
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
  - 删除、关机、重启、重装和改配等操作默认要求确认。
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "compshare-cli"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Command-line client for CompShare GPU compute"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """CompShare command-line client."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.3.2"
@@ -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 _wait_enabled(state, wait):
981
+ if _create_wait_enabled(wait):
941
982
  result["final"] = [
942
- _wait_for_instance(state, item, region=region, timeout=timeout) for item in ids
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 = locate_instance(state, instance)
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 print_only or state.json_output:
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
- raise typer.Exit(subprocess.call(argv))
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
- sys.stdout.write(json.dumps(safe, ensure_ascii=False, separators=(",", ":")) + "\n")
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
- sys.stdout.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n")
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(command: List[str], password: str) -> int:
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.1
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