remote-cmd-manager 1.2.2__tar.gz → 1.2.3__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.
- {remote_cmd_manager-1.2.2/remote_cmd_manager.egg-info → remote_cmd_manager-1.2.3}/PKG-INFO +1 -1
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/pyproject.toml +1 -1
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/__init__.py +2 -2
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/cli/main.py +6 -1
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/credential_provider.py +7 -1
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/storage_factory.py +19 -9
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3/remote_cmd_manager.egg-info}/PKG-INFO +1 -1
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/integration/conftest.py +3 -1
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/integration/test_ssh_connection.py +4 -12
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/performance/conftest.py +1 -3
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/performance/test_benchmarks.py +5 -14
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_async_batch_executor.py +11 -13
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_async_ssh_client.py +3 -9
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_batch_executor.py +2 -3
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_ssh_client.py +29 -16
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_storage_factory.py +48 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_sync_connection_pool.py +1 -3
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/LICENSE +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/MANIFEST.in +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/README.md +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/config.example.yaml +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/examples/basic_usage.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/examples/deploy_script.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/examples/nginx_batch_update.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/examples/system_health_check.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/cli/__init__.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/__init__.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/async_connection_pool.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/async_ssh_client.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/host.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/host_manager.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/ssh_client.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/sync_connection_pool.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/repository/__init__.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/repository/host_repository.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/repository/json_host_repository.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/repository/sqlite_host_repository.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/__init__.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/async_batch_executor.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/batch_executor.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/host_service.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/ssh_service.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/task_runner.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/utils/__init__.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/utils/config.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/utils/crypto.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/utils/exceptions.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/utils/logging_utils.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd_manager.egg-info/SOURCES.txt +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd_manager.egg-info/dependency_links.txt +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd_manager.egg-info/entry_points.txt +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd_manager.egg-info/requires.txt +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd_manager.egg-info/top_level.txt +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/requirements.txt +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/setup.cfg +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/__init__.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/conftest.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/performance/__init__.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_cli.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_config.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_credential_provider.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_crypto.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_host.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_host_manager.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_host_service.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_keyring_provider.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_logging_utils.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_repository.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_sqlite_repository.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_ssh_service.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/test_task_runner.py +0 -0
|
@@ -48,11 +48,11 @@ Remote CMD - SSH 远程服务器管理工具
|
|
|
48
48
|
- 文档: 参见 docs/ 目录
|
|
49
49
|
|
|
50
50
|
Author: Vae-Scrooge
|
|
51
|
-
Version: 1.2.
|
|
51
|
+
Version: 1.2.3
|
|
52
52
|
License: MIT
|
|
53
53
|
"""
|
|
54
54
|
|
|
55
|
-
__version__ = "1.2.
|
|
55
|
+
__version__ = "1.2.3"
|
|
56
56
|
__author__ = "Vae-Scrooge"
|
|
57
57
|
__email__ = "vae-scrooge@example.com"
|
|
58
58
|
__license__ = "MIT"
|
|
@@ -34,6 +34,7 @@ from remote_cmd.service.credential_provider import (
|
|
|
34
34
|
from remote_cmd.service.host_service import HostService
|
|
35
35
|
from remote_cmd.service.storage_factory import build_repository
|
|
36
36
|
from remote_cmd.utils.config import get_default_config_path, load_config
|
|
37
|
+
from remote_cmd.utils.crypto import CredentialEncryption
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
def _build_service(config_file: str, storage_backend: Optional[str] = None) -> HostService:
|
|
@@ -49,7 +50,11 @@ def _build_service(config_file: str, storage_backend: Optional[str] = None) -> H
|
|
|
49
50
|
explicitly, otherwise inferred from the config file extension:
|
|
50
51
|
.json -> JsonHostRepository; .db/.sqlite -> SqliteHostRepository.
|
|
51
52
|
"""
|
|
52
|
-
repo = build_repository(
|
|
53
|
+
repo = build_repository(
|
|
54
|
+
filepath=config_file,
|
|
55
|
+
storage_backend=storage_backend,
|
|
56
|
+
encryption=CredentialEncryption(),
|
|
57
|
+
)
|
|
53
58
|
cred_provider = ChainCredentialProvider(
|
|
54
59
|
[
|
|
55
60
|
EnvCredentialProvider(),
|
{remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/credential_provider.py
RENAMED
|
@@ -65,7 +65,13 @@ class EnvCredentialProvider(CredentialProvider):
|
|
|
65
65
|
|
|
66
66
|
@staticmethod
|
|
67
67
|
def _host_env_suffix(host_name: str) -> str:
|
|
68
|
-
"""
|
|
68
|
+
"""
|
|
69
|
+
将主机名转换为环境变量后缀:web1 -> WEB1,my-host -> MY_HOST
|
|
70
|
+
|
|
71
|
+
注意: 非字母数字字符统一归一化为下划线,因此 ``web-1`` 与 ``web_1``
|
|
72
|
+
会映射到同一个变量 ``..._WEB_1``。若同舰队同时存在这两种命名,
|
|
73
|
+
需避免依赖同名变量(如需区分请先统一主机命名规范)。
|
|
74
|
+
"""
|
|
69
75
|
normalized = "".join(c if c.isalnum() else "_" for c in host_name).upper()
|
|
70
76
|
return normalized
|
|
71
77
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
>>> repo = build_repository("hosts.json")
|
|
14
14
|
>>> repo = build_repository("hosts.db")
|
|
15
15
|
>>> repo = build_repository("hosts.json", storage_backend="sqlite")
|
|
16
|
+
>>> repo = build_repository("hosts.db", encryption=CredentialEncryption())
|
|
16
17
|
"""
|
|
17
18
|
|
|
18
19
|
from pathlib import Path
|
|
@@ -21,19 +22,24 @@ from typing import Callable, Optional
|
|
|
21
22
|
from remote_cmd.repository.host_repository import HostRepository
|
|
22
23
|
from remote_cmd.repository.json_host_repository import JsonHostRepository
|
|
23
24
|
from remote_cmd.repository.sqlite_host_repository import SqliteHostRepository
|
|
25
|
+
from remote_cmd.utils.crypto import CredentialEncryption
|
|
24
26
|
|
|
25
27
|
# 显式 storage_backend 取值 -> 仓库工厂
|
|
26
|
-
BACKEND_FACTORIES: dict[str, Callable[[str], HostRepository]] = {
|
|
27
|
-
"json": lambda path: JsonHostRepository(
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
BACKEND_FACTORIES: dict[str, Callable[[str, Optional[CredentialEncryption]], HostRepository]] = {
|
|
29
|
+
"json": lambda path, encryption: JsonHostRepository(
|
|
30
|
+
filepath=path, auto_load=True, encryption=encryption
|
|
31
|
+
),
|
|
32
|
+
"sqlite": lambda path, encryption: SqliteHostRepository(db_path=path, encryption=encryption),
|
|
33
|
+
"sqlite3": lambda path, encryption: SqliteHostRepository(db_path=path, encryption=encryption),
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
# 扩展名 -> 仓库工厂
|
|
33
|
-
EXTENSION_FACTORIES: dict[str, Callable[[str], HostRepository]] = {
|
|
34
|
-
".json": lambda path: JsonHostRepository(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
EXTENSION_FACTORIES: dict[str, Callable[[str, Optional[CredentialEncryption]], HostRepository]] = {
|
|
38
|
+
".json": lambda path, encryption: JsonHostRepository(
|
|
39
|
+
filepath=path, auto_load=True, encryption=encryption
|
|
40
|
+
),
|
|
41
|
+
".db": lambda path, encryption: SqliteHostRepository(db_path=path, encryption=encryption),
|
|
42
|
+
".sqlite": lambda path, encryption: SqliteHostRepository(db_path=path, encryption=encryption),
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
|
|
@@ -76,6 +82,7 @@ def resolve_storage_backend(filepath: str, storage_backend: Optional[str] = None
|
|
|
76
82
|
def build_repository(
|
|
77
83
|
filepath: str,
|
|
78
84
|
storage_backend: Optional[str] = None,
|
|
85
|
+
encryption: Optional[CredentialEncryption] = None,
|
|
79
86
|
) -> HostRepository:
|
|
80
87
|
"""
|
|
81
88
|
根据扩展名或显式存储后端构建 HostRepository。
|
|
@@ -83,6 +90,9 @@ def build_repository(
|
|
|
83
90
|
Args:
|
|
84
91
|
filepath: hosts 文件路径
|
|
85
92
|
storage_backend: 显式指定的存储后端(可选,优先于扩展名推断)
|
|
93
|
+
encryption: 凭据加密器(可选)。传入后仓库在写入时自动加密密码、
|
|
94
|
+
读取时自动解密,作为防御深度(即使调用方绕过 HostService
|
|
95
|
+
直接 save() 明文密码,落盘仍是密文)。
|
|
86
96
|
|
|
87
97
|
Returns:
|
|
88
98
|
HostRepository: 匹配的仓库实例
|
|
@@ -92,4 +102,4 @@ def build_repository(
|
|
|
92
102
|
"""
|
|
93
103
|
backend = resolve_storage_backend(filepath, storage_backend)
|
|
94
104
|
factory = BACKEND_FACTORIES[backend]
|
|
95
|
-
return factory(filepath)
|
|
105
|
+
return factory(filepath, encryption)
|
|
@@ -32,7 +32,9 @@ class MockSSHServerInterface(paramiko.ServerInterface):
|
|
|
32
32
|
return "password,publickey"
|
|
33
33
|
|
|
34
34
|
def check_channel_exec_request(
|
|
35
|
-
self,
|
|
35
|
+
self,
|
|
36
|
+
channel: paramiko.Channel, # noqa: ARG002
|
|
37
|
+
command: bytes, # noqa: ARG002
|
|
36
38
|
) -> bool:
|
|
37
39
|
return True
|
|
38
40
|
|
{remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/tests/integration/test_ssh_connection.py
RENAMED
|
@@ -29,9 +29,7 @@ class TestSSHConnectionIntegration:
|
|
|
29
29
|
|
|
30
30
|
def test_connect_with_password(self, mock_ssh_server, integration_host):
|
|
31
31
|
"""密码认证连接成功"""
|
|
32
|
-
config = ConnectionConfig(
|
|
33
|
-
port=integration_host.port, **self.TEST_CONFIG
|
|
34
|
-
)
|
|
32
|
+
config = ConnectionConfig(port=integration_host.port, **self.TEST_CONFIG)
|
|
35
33
|
client = SSHClient(config)
|
|
36
34
|
client.connect()
|
|
37
35
|
assert client.is_connected()
|
|
@@ -39,9 +37,7 @@ class TestSSHConnectionIntegration:
|
|
|
39
37
|
|
|
40
38
|
def test_connect_and_disconnect(self, mock_ssh_server, integration_host):
|
|
41
39
|
"""连接后断开"""
|
|
42
|
-
config = ConnectionConfig(
|
|
43
|
-
port=integration_host.port, **self.TEST_CONFIG
|
|
44
|
-
)
|
|
40
|
+
config = ConnectionConfig(port=integration_host.port, **self.TEST_CONFIG)
|
|
45
41
|
client = SSHClient(config)
|
|
46
42
|
client.connect()
|
|
47
43
|
assert client.is_connected()
|
|
@@ -73,18 +69,14 @@ class TestSSHConnectionIntegration:
|
|
|
73
69
|
|
|
74
70
|
def test_context_manager(self, mock_ssh_server, integration_host):
|
|
75
71
|
"""上下文管理器自动关闭连接"""
|
|
76
|
-
config = ConnectionConfig(
|
|
77
|
-
port=integration_host.port, **self.TEST_CONFIG
|
|
78
|
-
)
|
|
72
|
+
config = ConnectionConfig(port=integration_host.port, **self.TEST_CONFIG)
|
|
79
73
|
with SSHClient(config) as client:
|
|
80
74
|
assert client.is_connected()
|
|
81
75
|
assert not client.is_connected()
|
|
82
76
|
|
|
83
77
|
def test_double_disconnect_safe(self, mock_ssh_server, integration_host):
|
|
84
78
|
"""重复 disconnect 不抛异常"""
|
|
85
|
-
config = ConnectionConfig(
|
|
86
|
-
port=integration_host.port, **self.TEST_CONFIG
|
|
87
|
-
)
|
|
79
|
+
config = ConnectionConfig(port=integration_host.port, **self.TEST_CONFIG)
|
|
88
80
|
client = SSHClient(config)
|
|
89
81
|
client.connect()
|
|
90
82
|
client.disconnect()
|
|
@@ -84,9 +84,7 @@ def patched_async_client():
|
|
|
84
84
|
await asyncio.sleep(state["latency"])
|
|
85
85
|
return CommandResult(command=command, stdout="OK", stderr="", exit_code=0)
|
|
86
86
|
|
|
87
|
-
with patch(
|
|
88
|
-
"remote_cmd.service.async_batch_executor.AsyncSSHClient", _StubClient
|
|
89
|
-
):
|
|
87
|
+
with patch("remote_cmd.service.async_batch_executor.AsyncSSHClient", _StubClient):
|
|
90
88
|
yield state
|
|
91
89
|
|
|
92
90
|
|
|
@@ -116,9 +116,7 @@ class TestSchedulingOverhead:
|
|
|
116
116
|
|
|
117
117
|
# --- 异步路径 ---
|
|
118
118
|
hosts2 = make_hosts(n)
|
|
119
|
-
ex_async, _ = async_backend_factory(
|
|
120
|
-
hosts2, max_concurrency=concurrency, latency=latency
|
|
121
|
-
)
|
|
119
|
+
ex_async, _ = async_backend_factory(hosts2, max_concurrency=concurrency, latency=latency)
|
|
122
120
|
t0 = time.perf_counter()
|
|
123
121
|
asyncio.run(ex_async.execute([h.name for h in hosts2], "uptime"))
|
|
124
122
|
async_wall = time.perf_counter() - t0
|
|
@@ -130,8 +128,7 @@ class TestSchedulingOverhead:
|
|
|
130
128
|
)
|
|
131
129
|
# 断言:异步不应慢于同步(框架开销更小)。允许 10% 测量波动。
|
|
132
130
|
assert async_wall <= sync_wall * 1.1, (
|
|
133
|
-
f"异步 {async_wall:.4f}s 慢于同步 {sync_wall:.4f}s,"
|
|
134
|
-
f"speedup={speedup:.2f}x"
|
|
131
|
+
f"异步 {async_wall:.4f}s 慢于同步 {sync_wall:.4f}s,speedup={speedup:.2f}x"
|
|
135
132
|
)
|
|
136
133
|
|
|
137
134
|
|
|
@@ -143,9 +140,7 @@ class TestSchedulingOverhead:
|
|
|
143
140
|
class TestScalabilityCurve:
|
|
144
141
|
"""不同规模下记录耗时,输出可扩展性曲线与并发倍率。"""
|
|
145
142
|
|
|
146
|
-
@pytest.mark.parametrize(
|
|
147
|
-
"n, concurrency", [(10, 10), (50, 10), (100, 20), (500, 50)]
|
|
148
|
-
)
|
|
143
|
+
@pytest.mark.parametrize("n, concurrency", [(10, 10), (50, 10), (100, 20), (500, 50)])
|
|
149
144
|
def test_scalability_async(self, async_backend_factory, n, concurrency):
|
|
150
145
|
latency = 0.02
|
|
151
146
|
hosts = make_hosts(n)
|
|
@@ -166,9 +161,7 @@ class TestScalabilityCurve:
|
|
|
166
161
|
# 可扩展性断言:实测不应超过理论 1.5 倍
|
|
167
162
|
assert wall < ideal * 1.5
|
|
168
163
|
|
|
169
|
-
@pytest.mark.parametrize(
|
|
170
|
-
"n, concurrency", [(10, 10), (50, 10), (100, 20), (500, 50)]
|
|
171
|
-
)
|
|
164
|
+
@pytest.mark.parametrize("n, concurrency", [(10, 10), (50, 10), (100, 20), (500, 50)])
|
|
172
165
|
def test_scalability_sync(self, sync_backend_factory, n, concurrency):
|
|
173
166
|
latency = 0.02
|
|
174
167
|
hosts = make_hosts(n)
|
|
@@ -228,9 +221,7 @@ class TestUseAsyncDelegation:
|
|
|
228
221
|
|
|
229
222
|
from remote_cmd.service.batch_executor import BatchExecutor
|
|
230
223
|
|
|
231
|
-
with patch(
|
|
232
|
-
"remote_cmd.service.async_batch_executor.AsyncSSHClient", _Stub
|
|
233
|
-
):
|
|
224
|
+
with patch("remote_cmd.service.async_batch_executor.AsyncSSHClient", _Stub):
|
|
234
225
|
ex_delegated = BatchExecutor(
|
|
235
226
|
host_service=make_mock_service(hosts),
|
|
236
227
|
max_concurrency=10,
|
|
@@ -247,9 +247,7 @@ class TestAsyncConnectionPool:
|
|
|
247
247
|
async def test_monitor_loop_handles_exception(self, config, patched_client):
|
|
248
248
|
"""测试:监控循环中异常被捕获并继续运行"""
|
|
249
249
|
pool = AsyncConnectionPool(config=config, health_check_interval=0.01)
|
|
250
|
-
pool._cleanup_expired = AsyncMock(
|
|
251
|
-
side_effect=[RuntimeError("boom"), None, None, None]
|
|
252
|
-
)
|
|
250
|
+
pool._cleanup_expired = AsyncMock(side_effect=[RuntimeError("boom"), None, None, None])
|
|
253
251
|
pool._start_monitor()
|
|
254
252
|
await asyncio.sleep(0.05)
|
|
255
253
|
pool.stop_monitor()
|
|
@@ -259,6 +257,7 @@ class TestAsyncConnectionPool:
|
|
|
259
257
|
@pytest.mark.asyncio
|
|
260
258
|
async def test_create_connection_failure(self, config):
|
|
261
259
|
"""测试:创建连接失败时释放信号量并累计失败数"""
|
|
260
|
+
|
|
262
261
|
def failing_factory(cfg):
|
|
263
262
|
client = _client_mock()
|
|
264
263
|
client.connect = AsyncMock(side_effect=OSError("auth failed"))
|
|
@@ -266,10 +265,13 @@ class TestAsyncConnectionPool:
|
|
|
266
265
|
|
|
267
266
|
pool = AsyncConnectionPool(config=config, max_connections=1)
|
|
268
267
|
try:
|
|
269
|
-
with
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
268
|
+
with (
|
|
269
|
+
patch(
|
|
270
|
+
"remote_cmd.core.async_connection_pool.AsyncSSHClient",
|
|
271
|
+
side_effect=failing_factory,
|
|
272
|
+
),
|
|
273
|
+
pytest.raises(OSError),
|
|
274
|
+
):
|
|
273
275
|
await pool.acquire()
|
|
274
276
|
assert pool.get_metrics()["failed"] == 1
|
|
275
277
|
finally:
|
|
@@ -299,9 +301,7 @@ class TestAsyncConnectionPool:
|
|
|
299
301
|
@pytest.mark.asyncio
|
|
300
302
|
async def test_monitor_loop_cancelled(self, config, patched_client):
|
|
301
303
|
"""测试:监控任务可被取消"""
|
|
302
|
-
pool = AsyncConnectionPool(
|
|
303
|
-
config=config, health_check_interval=0.01
|
|
304
|
-
)
|
|
304
|
+
pool = AsyncConnectionPool(config=config, health_check_interval=0.01)
|
|
305
305
|
pool._start_monitor()
|
|
306
306
|
assert pool._monitor_task is not None
|
|
307
307
|
await asyncio.sleep(0.05)
|
|
@@ -322,9 +322,7 @@ class TestAsyncConnectionPool:
|
|
|
322
322
|
@pytest.mark.asyncio
|
|
323
323
|
async def test_cleanup_expired(self, config, patched_client):
|
|
324
324
|
"""测试:清理过期与空闲超时连接"""
|
|
325
|
-
pool = AsyncConnectionPool(
|
|
326
|
-
config=config, max_connections=3, max_lifetime=1, idle_timeout=1
|
|
327
|
-
)
|
|
325
|
+
pool = AsyncConnectionPool(config=config, max_connections=3, max_lifetime=1, idle_timeout=1)
|
|
328
326
|
try:
|
|
329
327
|
c1 = await pool.acquire()
|
|
330
328
|
c2 = await pool.acquire()
|
|
@@ -104,9 +104,7 @@ class TestAsyncSSHClientConnect:
|
|
|
104
104
|
|
|
105
105
|
@pytest.mark.asyncio
|
|
106
106
|
async def test_connect_timeout(self, config, patched_asyncssh):
|
|
107
|
-
patched_asyncssh.connect = AsyncMock(
|
|
108
|
-
side_effect=OSError("Connection timed out")
|
|
109
|
-
)
|
|
107
|
+
patched_asyncssh.connect = AsyncMock(side_effect=OSError("Connection timed out"))
|
|
110
108
|
client = AsyncSSHClient(config)
|
|
111
109
|
with pytest.raises(SSHConnectionError, match="connection timeout"):
|
|
112
110
|
await client.connect()
|
|
@@ -156,9 +154,7 @@ class TestAsyncSSHClientExecute:
|
|
|
156
154
|
|
|
157
155
|
@pytest.mark.asyncio
|
|
158
156
|
async def test_execute_failure_exit_code(self, config, patched_asyncssh, conn_mock):
|
|
159
|
-
conn_mock.run = AsyncMock(
|
|
160
|
-
return_value=MagicMock(stdout="", stderr="boom", exit_status=127)
|
|
161
|
-
)
|
|
157
|
+
conn_mock.run = AsyncMock(return_value=MagicMock(stdout="", stderr="boom", exit_status=127))
|
|
162
158
|
async with AsyncSSHClient(config) as client:
|
|
163
159
|
r = await client.execute("badcmd")
|
|
164
160
|
assert r.success is False
|
|
@@ -167,9 +163,7 @@ class TestAsyncSSHClientExecute:
|
|
|
167
163
|
|
|
168
164
|
@pytest.mark.asyncio
|
|
169
165
|
async def test_execute_sudo_without_password(self, config, patched_asyncssh, conn_mock):
|
|
170
|
-
conn_mock.run = AsyncMock(
|
|
171
|
-
return_value=MagicMock(stdout="ok", stderr="", exit_status=0)
|
|
172
|
-
)
|
|
166
|
+
conn_mock.run = AsyncMock(return_value=MagicMock(stdout="ok", stderr="", exit_status=0))
|
|
173
167
|
async with AsyncSSHClient(config) as client:
|
|
174
168
|
r = await client.execute_sudo("whoami")
|
|
175
169
|
assert r.success
|
|
@@ -282,9 +282,7 @@ class TestBatchExecutor:
|
|
|
282
282
|
future_map = {}
|
|
283
283
|
|
|
284
284
|
# 调用 _handle_interrupt
|
|
285
|
-
executor._handle_interrupt(
|
|
286
|
-
future_map, ["srv1", "srv2", "srv3"], "uptime", existing_results
|
|
287
|
-
)
|
|
285
|
+
executor._handle_interrupt(future_map, ["srv1", "srv2", "srv3"], "uptime", existing_results)
|
|
288
286
|
|
|
289
287
|
# 验证:srv1 保留原结果,srv2/srv3 被标记为 user interrupted
|
|
290
288
|
assert existing_results["srv1"].success is True
|
|
@@ -390,6 +388,7 @@ class TestBatchExecutor:
|
|
|
390
388
|
pass
|
|
391
389
|
|
|
392
390
|
import logging
|
|
391
|
+
|
|
393
392
|
with caplog.at_level(logging.WARNING, logger="remote_cmd.service.batch_executor"):
|
|
394
393
|
executor = BatchExecutor(host_service=service)
|
|
395
394
|
executor.execute(["srv1"], "uptime", progress_callback=async_callback)
|
|
@@ -256,13 +256,19 @@ class TestSSHClientExecute:
|
|
|
256
256
|
def test_execute_ssh_exception(self, mock_paramiko):
|
|
257
257
|
mock_paramiko.exec_command.side_effect = paramiko.SSHException("channel error")
|
|
258
258
|
config = ConnectionConfig(hostname="h", username="u")
|
|
259
|
-
with
|
|
259
|
+
with (
|
|
260
|
+
SSHClient(config) as client,
|
|
261
|
+
pytest.raises(SSHCommandError, match="command execution failed"),
|
|
262
|
+
):
|
|
260
263
|
client.execute("ls")
|
|
261
264
|
|
|
262
265
|
def test_execute_os_error(self, mock_paramiko):
|
|
263
266
|
mock_paramiko.exec_command.side_effect = OSError("pipe broken")
|
|
264
267
|
config = ConnectionConfig(hostname="h", username="u")
|
|
265
|
-
with
|
|
268
|
+
with (
|
|
269
|
+
SSHClient(config) as client,
|
|
270
|
+
pytest.raises(SSHCommandError, match="command execution failed"),
|
|
271
|
+
):
|
|
266
272
|
client.execute("ls")
|
|
267
273
|
|
|
268
274
|
def test_execute_stdout_decoding(self, mock_paramiko):
|
|
@@ -323,8 +329,9 @@ class TestSSHClientFileTransfer:
|
|
|
323
329
|
|
|
324
330
|
def test_upload_missing_local(self, mock_paramiko): # noqa: ARG002
|
|
325
331
|
config = ConnectionConfig(hostname="h", username="u")
|
|
326
|
-
with
|
|
327
|
-
|
|
332
|
+
with (
|
|
333
|
+
SSHClient(config) as client,
|
|
334
|
+
pytest.raises(SSHFileTransferError, match="Local file not found"),
|
|
328
335
|
):
|
|
329
336
|
client.upload_file("/no/file", "/remote/x")
|
|
330
337
|
|
|
@@ -334,8 +341,9 @@ class TestSSHClientFileTransfer:
|
|
|
334
341
|
sftp = mock_paramiko.open_sftp.return_value
|
|
335
342
|
sftp.put.side_effect = paramiko.SSHException("transfer fail")
|
|
336
343
|
config = ConnectionConfig(hostname="h", username="u")
|
|
337
|
-
with
|
|
338
|
-
|
|
344
|
+
with (
|
|
345
|
+
SSHClient(config) as client,
|
|
346
|
+
pytest.raises(SSHFileTransferError, match="file upload failed"),
|
|
339
347
|
):
|
|
340
348
|
client.upload_file(str(local), "/remote/x")
|
|
341
349
|
|
|
@@ -352,8 +360,9 @@ class TestSSHClientFileTransfer:
|
|
|
352
360
|
sftp = mock_paramiko.open_sftp.return_value
|
|
353
361
|
sftp.get.side_effect = OSError("disk full")
|
|
354
362
|
config = ConnectionConfig(hostname="h", username="u")
|
|
355
|
-
with
|
|
356
|
-
|
|
363
|
+
with (
|
|
364
|
+
SSHClient(config) as client,
|
|
365
|
+
pytest.raises(SSHFileTransferError, match="file download failed"),
|
|
357
366
|
):
|
|
358
367
|
client.download_file("/remote/x", str(tmp_path / "x.txt"))
|
|
359
368
|
|
|
@@ -377,8 +386,9 @@ class TestSSHClientFileTransfer:
|
|
|
377
386
|
sftp = mock_paramiko.open_sftp.return_value
|
|
378
387
|
sftp.listdir_attr.side_effect = paramiko.SSHException("ls fail")
|
|
379
388
|
config = ConnectionConfig(hostname="h", username="u")
|
|
380
|
-
with
|
|
381
|
-
|
|
389
|
+
with (
|
|
390
|
+
SSHClient(config) as client,
|
|
391
|
+
pytest.raises(SSHFileTransferError, match="failed to list remote directory"),
|
|
382
392
|
):
|
|
383
393
|
client.list_remote_directory("/")
|
|
384
394
|
|
|
@@ -400,8 +410,9 @@ class TestSSHClientFileTransfer:
|
|
|
400
410
|
sftp = mock_paramiko.open_sftp.return_value
|
|
401
411
|
sftp.remove.side_effect = paramiko.SSHException("rm fail")
|
|
402
412
|
config = ConnectionConfig(hostname="h", username="u")
|
|
403
|
-
with
|
|
404
|
-
|
|
413
|
+
with (
|
|
414
|
+
SSHClient(config) as client,
|
|
415
|
+
pytest.raises(SSHFileTransferError, match="failed to delete remote file"),
|
|
405
416
|
):
|
|
406
417
|
client.remove_remote_file("/remote/x.txt")
|
|
407
418
|
|
|
@@ -457,8 +468,9 @@ class TestSSHClientFileTransfer:
|
|
|
457
468
|
sftp = mock_paramiko.open_sftp.return_value
|
|
458
469
|
sftp.stat.side_effect = paramiko.SSHException("stat fail")
|
|
459
470
|
config = ConnectionConfig(hostname="h", username="u")
|
|
460
|
-
with
|
|
461
|
-
|
|
471
|
+
with (
|
|
472
|
+
SSHClient(config) as client,
|
|
473
|
+
pytest.raises(SSHFileTransferError, match="failed to get file info"),
|
|
462
474
|
):
|
|
463
475
|
client.get_remote_file_info("/remote/x.txt")
|
|
464
476
|
|
|
@@ -501,8 +513,9 @@ class TestSSHClientFileTransfer:
|
|
|
501
513
|
sftp.stat.side_effect = OSError("not found")
|
|
502
514
|
sftp.mkdir.side_effect = paramiko.SSHException("mkdir fail")
|
|
503
515
|
config = ConnectionConfig(hostname="h", username="u")
|
|
504
|
-
with
|
|
505
|
-
|
|
516
|
+
with (
|
|
517
|
+
SSHClient(config) as c,
|
|
518
|
+
pytest.raises(SSHFileTransferError, match="failed to create remote directory"),
|
|
506
519
|
):
|
|
507
520
|
c.create_remote_directory("/a/b")
|
|
508
521
|
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
- 未知扩展名且无显式配置时抛出 ValueError
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
|
|
10
13
|
import pytest
|
|
11
14
|
|
|
12
15
|
from remote_cmd.repository.json_host_repository import JsonHostRepository
|
|
@@ -80,3 +83,48 @@ class TestBuildRepository:
|
|
|
80
83
|
"""默认 hosts.json 行为保持为 JSON 存储,不破坏兼容性"""
|
|
81
84
|
repo = build_repository(str(tmp_path / "hosts.json"))
|
|
82
85
|
assert isinstance(repo, JsonHostRepository)
|
|
86
|
+
|
|
87
|
+
def test_encryption_wired_into_json_repo(self, tmp_path):
|
|
88
|
+
"""测试:传入 encryption 后 JSON 仓库在写入时加密密码"""
|
|
89
|
+
from remote_cmd.core.host import Host
|
|
90
|
+
from remote_cmd.utils.crypto import CredentialEncryption
|
|
91
|
+
|
|
92
|
+
path = str(tmp_path / "hosts.json")
|
|
93
|
+
repo = build_repository(path, encryption=CredentialEncryption())
|
|
94
|
+
repo.save(Host(name="srv1", hostname="10.0.0.1", username="admin", password="secret"))
|
|
95
|
+
repo.flush()
|
|
96
|
+
|
|
97
|
+
with open(path, encoding="utf-8") as f:
|
|
98
|
+
raw = json.load(f)
|
|
99
|
+
stored = raw["hosts"]["srv1"]["password"]
|
|
100
|
+
assert stored != "secret"
|
|
101
|
+
assert stored.startswith("$encrypted$") or "encrypted" in stored
|
|
102
|
+
|
|
103
|
+
def test_encryption_wired_into_sqlite_repo(self, tmp_path):
|
|
104
|
+
"""测试:传入 encryption 后 SQLite 仓库在写入时加密密码"""
|
|
105
|
+
from remote_cmd.core.host import Host
|
|
106
|
+
from remote_cmd.utils.crypto import CredentialEncryption
|
|
107
|
+
|
|
108
|
+
path = str(tmp_path / "hosts.db")
|
|
109
|
+
repo = build_repository(path, encryption=CredentialEncryption())
|
|
110
|
+
repo.save(Host(name="srv1", hostname="10.0.0.1", username="admin", password="secret"))
|
|
111
|
+
|
|
112
|
+
conn = sqlite3.connect(path)
|
|
113
|
+
try:
|
|
114
|
+
row = conn.execute("SELECT password FROM hosts WHERE name = ?", ("srv1",)).fetchone()
|
|
115
|
+
assert row[0] != "secret"
|
|
116
|
+
finally:
|
|
117
|
+
conn.close()
|
|
118
|
+
|
|
119
|
+
def test_without_encryption_preserves_old_behavior(self, tmp_path):
|
|
120
|
+
"""测试:不传 encryption 时保持原有行为(明文直存,兼容)"""
|
|
121
|
+
from remote_cmd.core.host import Host
|
|
122
|
+
|
|
123
|
+
path = str(tmp_path / "hosts.json")
|
|
124
|
+
repo = build_repository(path)
|
|
125
|
+
repo.save(Host(name="srv1", hostname="10.0.0.1", username="admin", password="secret"))
|
|
126
|
+
repo.flush()
|
|
127
|
+
|
|
128
|
+
with open(path, encoding="utf-8") as f:
|
|
129
|
+
raw = json.load(f)
|
|
130
|
+
assert raw["hosts"]["srv1"]["password"] == "secret"
|
|
@@ -261,9 +261,7 @@ class TestSyncConnectionPool:
|
|
|
261
261
|
# 强行使 conn 空闲超时
|
|
262
262
|
pool._meta[id(conn)]["last_used"] = time.time() - 1000
|
|
263
263
|
|
|
264
|
-
conn.execute.return_value = CommandResult(
|
|
265
|
-
command="true", stdout="", stderr="", exit_code=0
|
|
266
|
-
)
|
|
264
|
+
conn.execute.return_value = CommandResult(command="true", stdout="", stderr="", exit_code=0)
|
|
267
265
|
conn2 = pool.acquire()
|
|
268
266
|
assert conn2 is conn # 探活成功,复用
|
|
269
267
|
pool.close_all()
|
|
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
|
{remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/async_connection_pool.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/core/sync_connection_pool.py
RENAMED
|
File without changes
|
|
File without changes
|
{remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/repository/host_repository.py
RENAMED
|
File without changes
|
{remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/repository/json_host_repository.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd/service/async_batch_executor.py
RENAMED
|
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
|
{remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd_manager.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd_manager.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd_manager.egg-info/requires.txt
RENAMED
|
File without changes
|
{remote_cmd_manager-1.2.2 → remote_cmd_manager-1.2.3}/remote_cmd_manager.egg-info/top_level.txt
RENAMED
|
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
|