remote-cmd-manager 2.3.0__tar.gz → 2.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {remote_cmd_manager-2.3.0/remote_cmd_manager.egg-info → remote_cmd_manager-2.4.0}/PKG-INFO +17 -15
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/README.md +16 -14
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/__init__.py +1 -1
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/_version.py +1 -1
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/_host_runner.py +60 -3
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/async_batch_executor.py +15 -1
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/batch_executor.py +23 -2
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/logging_utils.py +7 -2
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0/remote_cmd_manager.egg-info}/PKG-INFO +17 -15
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_async_batch_executor.py +118 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_batch_executor.py +100 -1
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_logging_utils.py +14 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/LICENSE +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/MANIFEST.in +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/config.example.yaml +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/examples/basic_usage.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/examples/deploy_script.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/examples/nginx_batch_update.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/examples/system_health_check.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/pyproject.toml +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/cli/__init__.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/cli/main.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/__init__.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/async_connection_pool.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/async_ssh_client.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/host.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/host_manager.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/ssh_client.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/sync_connection_pool.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/repository/__init__.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/repository/host_repository.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/repository/json_host_repository.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/repository/sqlite_host_repository.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/__init__.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/_pool_policy.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/_types.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/credential_provider.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/host_service.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/retry_policy.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/ssh_service.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/storage_factory.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/task_runner.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/__init__.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/config.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/credential_guard.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/crypto.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/exceptions.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/SOURCES.txt +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/dependency_links.txt +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/entry_points.txt +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/requires.txt +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/top_level.txt +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/requirements.txt +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/setup.cfg +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/__init__.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/conftest.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/integration/conftest.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/integration/test_ssh_connection.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/performance/__init__.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/performance/conftest.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/performance/test_benchmarks.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_async_ssh_client.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_cli.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_config.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_credential_provider.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_crypto.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_exceptions.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_host.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_host_manager.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_host_service.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_keyring_provider.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_pool_policy.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_release_metadata.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_repository.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_retry_policy.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_sqlite_repository.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_ssh_client.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_ssh_service.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_storage_factory.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_sync_connection_pool.py +0 -0
- {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_task_runner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remote_cmd_manager
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Cross-platform SSH remote server management tool (Linux/macOS/Windows) — lightweight, zero-configuration CLI and Python API for managing servers over SSH. Supports host CRUD, batch commands, file transfer, credential encryption, async execution, and more.
|
|
5
5
|
Author-email: Vae-Scrooge <vaescrooge@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -89,28 +89,30 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
|
|
|
89
89
|
|
|
90
90
|
---
|
|
91
91
|
|
|
92
|
-
## v2.
|
|
92
|
+
## v2.4.0 Release Highlights
|
|
93
93
|
|
|
94
|
-
v2.
|
|
95
|
-
|
|
94
|
+
v2.4.0 adds an opt-in retained-output cap for batch execution and cleans up logging
|
|
95
|
+
handler resources, with default behavior and public result schemas unchanged. See the
|
|
96
96
|
[full migration notes](./CHANGELOG.md) before upgrading automated callers.
|
|
97
97
|
|
|
98
|
-
###
|
|
98
|
+
### Bounded Batch Output
|
|
99
99
|
|
|
100
|
-
-
|
|
101
|
-
-
|
|
102
|
-
- The
|
|
103
|
-
- Timed-out or cancelled SFTP sessions are closed and discarded, so a stale or desynchronized channel can never be reused.
|
|
100
|
+
- `BatchExecutor` / `AsyncBatchExecutor` accept an optional `max_output_bytes` (default `None`): `None` keeps full `stdout`/`stderr`, while a positive value caps each host's retained streams and appends a `[output truncated: N bytes omitted]` marker.
|
|
101
|
+
- Truncation is deterministic and UTF-8 byte-boundary safe, applies to `stdout` and `stderr` independently, and never changes command success/failure or exit codes.
|
|
102
|
+
- The cap bounds the retained `BatchResult`; the SSH client may still hold full output transiently during execution, so it is not a process-wide RSS limit.
|
|
104
103
|
|
|
105
|
-
###
|
|
104
|
+
### Reliability
|
|
106
105
|
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
|
|
106
|
+
- `setup_logging` now closes previous root-logger handlers before removing them, avoiding unclosed-file resource warnings when logging is reconfigured.
|
|
107
|
+
- Connection-pool lifetime wording in the documentation now matches the actual behavior: internal pools are created lazily per host and closed as soon as that host finishes (including its retries).
|
|
108
|
+
|
|
109
|
+
### Compatibility
|
|
110
|
+
|
|
111
|
+
- Default behavior is unchanged (`max_output_bytes=None` retains full output) and the public `BatchResult` / `BatchHostResult` schemas are unchanged.
|
|
110
112
|
|
|
111
113
|
## Table of Contents
|
|
112
114
|
|
|
113
|
-
- [v2.
|
|
115
|
+
- [v2.4.0 Release Highlights](#v240-release-highlights)
|
|
114
116
|
- [Why Remote CMD?](#why-remote-cmd)
|
|
115
117
|
- [Quick Start](#quick-start)
|
|
116
118
|
- [Use Cases](#use-cases)
|
|
@@ -258,7 +260,7 @@ with SSHClient(config) as client:
|
|
|
258
260
|
| **File Transfer** | Upload/download via SFTP (`remote-cmd upload/download`) |
|
|
259
261
|
| **Host Management** | CRUD with pluggable JSON or **SQLite** persistence |
|
|
260
262
|
| **Tag System** | Filter hosts by tag (e.g., `production`, `web`, `db`) |
|
|
261
|
-
| **Batch Ops** | Run commands across any host group, synchronously or asynchronously |
|
|
263
|
+
| **Batch Ops** | Run commands across any host group, synchronously or asynchronously; optional per-host retained-output cap (`max_output_bytes`) |
|
|
262
264
|
| **Async Kernel** | `AsyncSSHClient` / `AsyncConnectionPool` / `AsyncBatchExecutor` via the `[async]` extra |
|
|
263
265
|
| **Task Runner** | Track and schedule long-running remote tasks with statuses (`TaskRunner`) |
|
|
264
266
|
| **Connection Test** | Test all host SSH connections and report status |
|
|
@@ -36,28 +36,30 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
|
|
|
36
36
|
|
|
37
37
|
---
|
|
38
38
|
|
|
39
|
-
## v2.
|
|
39
|
+
## v2.4.0 Release Highlights
|
|
40
40
|
|
|
41
|
-
v2.
|
|
42
|
-
|
|
41
|
+
v2.4.0 adds an opt-in retained-output cap for batch execution and cleans up logging
|
|
42
|
+
handler resources, with default behavior and public result schemas unchanged. See the
|
|
43
43
|
[full migration notes](./CHANGELOG.md) before upgrading automated callers.
|
|
44
44
|
|
|
45
|
-
###
|
|
45
|
+
### Bounded Batch Output
|
|
46
46
|
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
- The
|
|
50
|
-
- Timed-out or cancelled SFTP sessions are closed and discarded, so a stale or desynchronized channel can never be reused.
|
|
47
|
+
- `BatchExecutor` / `AsyncBatchExecutor` accept an optional `max_output_bytes` (default `None`): `None` keeps full `stdout`/`stderr`, while a positive value caps each host's retained streams and appends a `[output truncated: N bytes omitted]` marker.
|
|
48
|
+
- Truncation is deterministic and UTF-8 byte-boundary safe, applies to `stdout` and `stderr` independently, and never changes command success/failure or exit codes.
|
|
49
|
+
- The cap bounds the retained `BatchResult`; the SSH client may still hold full output transiently during execution, so it is not a process-wide RSS limit.
|
|
51
50
|
|
|
52
|
-
###
|
|
51
|
+
### Reliability
|
|
53
52
|
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
|
|
53
|
+
- `setup_logging` now closes previous root-logger handlers before removing them, avoiding unclosed-file resource warnings when logging is reconfigured.
|
|
54
|
+
- Connection-pool lifetime wording in the documentation now matches the actual behavior: internal pools are created lazily per host and closed as soon as that host finishes (including its retries).
|
|
55
|
+
|
|
56
|
+
### Compatibility
|
|
57
|
+
|
|
58
|
+
- Default behavior is unchanged (`max_output_bytes=None` retains full output) and the public `BatchResult` / `BatchHostResult` schemas are unchanged.
|
|
57
59
|
|
|
58
60
|
## Table of Contents
|
|
59
61
|
|
|
60
|
-
- [v2.
|
|
62
|
+
- [v2.4.0 Release Highlights](#v240-release-highlights)
|
|
61
63
|
- [Why Remote CMD?](#why-remote-cmd)
|
|
62
64
|
- [Quick Start](#quick-start)
|
|
63
65
|
- [Use Cases](#use-cases)
|
|
@@ -205,7 +207,7 @@ with SSHClient(config) as client:
|
|
|
205
207
|
| **File Transfer** | Upload/download via SFTP (`remote-cmd upload/download`) |
|
|
206
208
|
| **Host Management** | CRUD with pluggable JSON or **SQLite** persistence |
|
|
207
209
|
| **Tag System** | Filter hosts by tag (e.g., `production`, `web`, `db`) |
|
|
208
|
-
| **Batch Ops** | Run commands across any host group, synchronously or asynchronously |
|
|
210
|
+
| **Batch Ops** | Run commands across any host group, synchronously or asynchronously; optional per-host retained-output cap (`max_output_bytes`) |
|
|
209
211
|
| **Async Kernel** | `AsyncSSHClient` / `AsyncConnectionPool` / `AsyncBatchExecutor` via the `[async]` extra |
|
|
210
212
|
| **Task Runner** | Track and schedule long-running remote tasks with statuses (`TaskRunner`) |
|
|
211
213
|
| **Connection Test** | Test all host SSH connections and report status |
|
|
@@ -19,17 +19,71 @@
|
|
|
19
19
|
... )
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
|
-
from typing import Union
|
|
22
|
+
from typing import Optional, Union
|
|
23
23
|
|
|
24
24
|
from remote_cmd.core.host import Host
|
|
25
25
|
from remote_cmd.core.ssh_client import CommandResult, ConnectionConfig
|
|
26
26
|
from remote_cmd.service._types import BatchHostResult
|
|
27
27
|
from remote_cmd.service.host_service import HostService
|
|
28
|
+
from remote_cmd.utils.exceptions import ValidationError
|
|
29
|
+
|
|
30
|
+
# 输出截断标记:追加在被截断的输出流末尾(确定性、便于测试与用户识别)
|
|
31
|
+
OUTPUT_TRUNCATION_MARKER = "\n[output truncated: {omitted} bytes omitted]"
|
|
28
32
|
|
|
29
33
|
# host 解析结果:成功返回 Host,失败返回带错误信息的 BatchHostResult
|
|
30
34
|
ResolveOutcome = Union[Host, BatchHostResult]
|
|
31
35
|
|
|
32
36
|
|
|
37
|
+
def validate_max_output_bytes(value: Optional[int]) -> Optional[int]:
|
|
38
|
+
"""校验批量结果的单流输出上限并原样返回。
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
value: ``None``(不截断)或正整数(每台主机每个输出流保留的最大字节数)
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Optional[int]: 校验后的值
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValidationError: 非 None、非正整数(bool 亦被拒绝,避免 True/False
|
|
48
|
+
被当作 1/0 使用)
|
|
49
|
+
"""
|
|
50
|
+
if value is None:
|
|
51
|
+
return None
|
|
52
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
53
|
+
raise ValidationError(
|
|
54
|
+
f"max_output_bytes must be None or a positive integer, got: {value!r}"
|
|
55
|
+
)
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def truncate_output(text: str, max_bytes: Optional[int]) -> str:
|
|
60
|
+
"""按 UTF-8 字节上限确定性地截断单个输出流。
|
|
61
|
+
|
|
62
|
+
``max_bytes`` 为 ``None`` 时原样返回(保持既有行为,绝不静默截断)。
|
|
63
|
+
超过上限时按 UTF-8 字节切片(丢弃被切断的多字节序列尾部)并追加固定
|
|
64
|
+
格式的截断标记,标记不计入 ``max_bytes``。截断只影响保留的输出文本,
|
|
65
|
+
不改变命令的成功/失败语义与退出码。
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
text: 已解码的输出文本
|
|
69
|
+
max_bytes: 保留的最大字节数(按 ``text.encode("utf-8")`` 度量),
|
|
70
|
+
或 ``None`` 表示不截断
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
str: 原文本或截断后的文本(含标记)
|
|
74
|
+
"""
|
|
75
|
+
if max_bytes is None or not text:
|
|
76
|
+
return text
|
|
77
|
+
encoded = text.encode("utf-8", errors="replace")
|
|
78
|
+
if len(encoded) <= max_bytes:
|
|
79
|
+
return text
|
|
80
|
+
prefix = encoded[:max_bytes].decode("utf-8", errors="ignore")
|
|
81
|
+
# omitted 按“原始字节数 - 实际保留字节数”计算:UTF-8 边界处的
|
|
82
|
+
# 残字节不会被错误计入保留量
|
|
83
|
+
omitted = len(encoded) - len(prefix.encode("utf-8"))
|
|
84
|
+
return prefix + OUTPUT_TRUNCATION_MARKER.format(omitted=omitted)
|
|
85
|
+
|
|
86
|
+
|
|
33
87
|
def build_connection_config(host: Host, timeout: int) -> ConnectionConfig:
|
|
34
88
|
"""由主机配置构造 SSH 连接配置。
|
|
35
89
|
|
|
@@ -92,6 +146,7 @@ def to_host_result(
|
|
|
92
146
|
command: str,
|
|
93
147
|
cmd_result: CommandResult,
|
|
94
148
|
duration: float,
|
|
149
|
+
max_output_bytes: Optional[int] = None,
|
|
95
150
|
) -> BatchHostResult:
|
|
96
151
|
"""由命令结果构造成功的单主机结果。
|
|
97
152
|
|
|
@@ -100,6 +155,8 @@ def to_host_result(
|
|
|
100
155
|
command: 执行的命令
|
|
101
156
|
cmd_result: 命令执行结果
|
|
102
157
|
duration: 本次执行耗时(秒)
|
|
158
|
+
max_output_bytes: 每个输出流保留的最大字节数;``None`` 表示
|
|
159
|
+
保留完整输出(既有行为)。仅影响保留文本,不影响成功/失败
|
|
103
160
|
|
|
104
161
|
Returns:
|
|
105
162
|
BatchHostResult: 成功结果
|
|
@@ -108,8 +165,8 @@ def to_host_result(
|
|
|
108
165
|
host=host_name,
|
|
109
166
|
success=cmd_result.success,
|
|
110
167
|
command=command,
|
|
111
|
-
stdout=cmd_result.stdout,
|
|
112
|
-
stderr=cmd_result.stderr,
|
|
168
|
+
stdout=truncate_output(cmd_result.stdout, max_output_bytes),
|
|
169
|
+
stderr=truncate_output(cmd_result.stderr, max_output_bytes),
|
|
113
170
|
exit_code=cmd_result.exit_code,
|
|
114
171
|
duration=duration,
|
|
115
172
|
)
|
{remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/async_batch_executor.py
RENAMED
|
@@ -36,6 +36,7 @@ from remote_cmd.service._host_runner import (
|
|
|
36
36
|
build_connection_config,
|
|
37
37
|
resolve_host_or_error,
|
|
38
38
|
to_host_result,
|
|
39
|
+
validate_max_output_bytes,
|
|
39
40
|
)
|
|
40
41
|
from remote_cmd.service._types import BatchHostResult, BatchResult, ProgressCallback
|
|
41
42
|
from remote_cmd.service.host_service import HostService
|
|
@@ -61,6 +62,11 @@ class AsyncBatchExecutor:
|
|
|
61
62
|
内部按主机惰性创建 AsyncConnectionPool,该主机执行结束后
|
|
62
63
|
立即关闭;内部池上限 1 条连接,整批并发存活连接数受
|
|
63
64
|
``max_concurrency`` 约束。
|
|
65
|
+
max_output_bytes: 每台主机每个输出流(stdout/stderr)保留的最大
|
|
66
|
+
字节数(UTF-8),默认 ``None`` 保留完整输出(既有行为)。
|
|
67
|
+
设置正整数时输出会被确定性截断并追加 ``[output truncated: N
|
|
68
|
+
bytes omitted]`` 标记;截断不改变命令成功/失败与退出码。
|
|
69
|
+
仅约束保留的批量结果,客户端在执行期间仍可能短暂持有完整输出
|
|
64
70
|
|
|
65
71
|
连接池所有权约定(与同步 BatchExecutor 一致):
|
|
66
72
|
|
|
@@ -77,6 +83,7 @@ class AsyncBatchExecutor:
|
|
|
77
83
|
max_concurrency: int = 10,
|
|
78
84
|
command_timeout: int = 30,
|
|
79
85
|
pool_factory: Optional[PoolFactory] = None,
|
|
86
|
+
max_output_bytes: Optional[int] = None,
|
|
80
87
|
) -> None:
|
|
81
88
|
if max_concurrency < 1:
|
|
82
89
|
raise ValueError(f"max_concurrency must be >= 1, got: {max_concurrency}")
|
|
@@ -86,6 +93,7 @@ class AsyncBatchExecutor:
|
|
|
86
93
|
self._max_concurrency = max_concurrency
|
|
87
94
|
self._command_timeout = command_timeout
|
|
88
95
|
self._pool_factory = pool_factory
|
|
96
|
+
self._max_output_bytes = validate_max_output_bytes(max_output_bytes)
|
|
89
97
|
|
|
90
98
|
async def execute(
|
|
91
99
|
self,
|
|
@@ -318,7 +326,13 @@ class AsyncBatchExecutor:
|
|
|
318
326
|
command,
|
|
319
327
|
timeout=self._command_timeout,
|
|
320
328
|
)
|
|
321
|
-
return to_host_result(
|
|
329
|
+
return to_host_result(
|
|
330
|
+
host_name,
|
|
331
|
+
command,
|
|
332
|
+
cmd_result,
|
|
333
|
+
time.time() - start,
|
|
334
|
+
max_output_bytes=self._max_output_bytes,
|
|
335
|
+
)
|
|
322
336
|
except Exception as e: # noqa: BLE001
|
|
323
337
|
last_error = str(e)
|
|
324
338
|
last_duration = time.time() - start
|
|
@@ -40,6 +40,7 @@ from remote_cmd.service._host_runner import (
|
|
|
40
40
|
build_connection_config,
|
|
41
41
|
resolve_host_or_error,
|
|
42
42
|
to_host_result,
|
|
43
|
+
validate_max_output_bytes,
|
|
43
44
|
)
|
|
44
45
|
from remote_cmd.service._types import (
|
|
45
46
|
BatchHostResult,
|
|
@@ -84,6 +85,11 @@ class BatchExecutor:
|
|
|
84
85
|
立即关闭;内部池 ``max_connections=1``,整批并发存活连接数受
|
|
85
86
|
``max_concurrency`` 约束。工厂返回的池类型须与内核匹配
|
|
86
87
|
(见 PoolFactory 注释)。
|
|
88
|
+
max_output_bytes: 每台主机每个输出流(stdout/stderr)保留的最大
|
|
89
|
+
字节数(UTF-8),默认 ``None`` 保留完整输出(既有行为)。
|
|
90
|
+
设置正整数时输出会被确定性截断并追加 ``[output truncated: N
|
|
91
|
+
bytes omitted]`` 标记;截断不改变命令成功/失败与退出码。
|
|
92
|
+
仅约束保留的批量结果,客户端在执行期间仍可能短暂持有完整输出
|
|
87
93
|
|
|
88
94
|
连接池所有权约定(与 AsyncBatchExecutor 一致):
|
|
89
95
|
|
|
@@ -105,6 +111,7 @@ class BatchExecutor:
|
|
|
105
111
|
command_timeout: int = 30,
|
|
106
112
|
use_async: bool = False,
|
|
107
113
|
pool_factory: Optional[PoolFactory] = None,
|
|
114
|
+
max_output_bytes: Optional[int] = None,
|
|
108
115
|
) -> None:
|
|
109
116
|
if max_concurrency < 1:
|
|
110
117
|
raise ValueError(f"max_concurrency must be >= 1, got: {max_concurrency}")
|
|
@@ -115,6 +122,7 @@ class BatchExecutor:
|
|
|
115
122
|
self._command_timeout = command_timeout
|
|
116
123
|
self._use_async = use_async
|
|
117
124
|
self._pool_factory = pool_factory
|
|
125
|
+
self._max_output_bytes = validate_max_output_bytes(max_output_bytes)
|
|
118
126
|
# 延迟导入以避免在未安装 asyncssh 的环境下的导入失败
|
|
119
127
|
# 使用前向引用避免在模块加载期引入 asyncssh 硬依赖(开启 use_async 时才惰性导入)
|
|
120
128
|
self._async_executor: Optional["AsyncBatchExecutor"] = None # noqa: UP037
|
|
@@ -126,6 +134,7 @@ class BatchExecutor:
|
|
|
126
134
|
max_concurrency=max_concurrency,
|
|
127
135
|
command_timeout=command_timeout,
|
|
128
136
|
pool_factory=pool_factory,
|
|
137
|
+
max_output_bytes=self._max_output_bytes,
|
|
129
138
|
)
|
|
130
139
|
|
|
131
140
|
def execute(
|
|
@@ -458,7 +467,13 @@ class BatchExecutor:
|
|
|
458
467
|
# 连接池模式:复用主机连接,避免每次操作握手
|
|
459
468
|
with pool.acquire_context() as client:
|
|
460
469
|
cmd_result = client.execute(command, timeout=self._command_timeout)
|
|
461
|
-
return to_host_result(
|
|
470
|
+
return to_host_result(
|
|
471
|
+
host_name,
|
|
472
|
+
command,
|
|
473
|
+
cmd_result,
|
|
474
|
+
time.time() - start,
|
|
475
|
+
max_output_bytes=self._max_output_bytes,
|
|
476
|
+
)
|
|
462
477
|
|
|
463
478
|
# 非连接池路径:try/finally 确保即使 execute() 抛异常,
|
|
464
479
|
# disconnect() 也会执行,避免 SSH 连接泄漏
|
|
@@ -469,7 +484,13 @@ class BatchExecutor:
|
|
|
469
484
|
finally:
|
|
470
485
|
client.disconnect()
|
|
471
486
|
|
|
472
|
-
return to_host_result(
|
|
487
|
+
return to_host_result(
|
|
488
|
+
host_name,
|
|
489
|
+
command,
|
|
490
|
+
cmd_result,
|
|
491
|
+
time.time() - start,
|
|
492
|
+
max_output_bytes=self._max_output_bytes,
|
|
493
|
+
)
|
|
473
494
|
|
|
474
495
|
except Exception as e: # noqa: BLE001
|
|
475
496
|
duration = time.time() - start
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
- 错误消息中的密码使用 [REDACTED] 替换
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
+
import contextlib
|
|
15
16
|
import logging
|
|
16
17
|
import re
|
|
17
18
|
import sys
|
|
@@ -119,8 +120,12 @@ def setup_logging(
|
|
|
119
120
|
root_logger = logging.getLogger()
|
|
120
121
|
root_logger.setLevel(getattr(logging, level.upper(), logging.INFO))
|
|
121
122
|
|
|
122
|
-
#
|
|
123
|
-
|
|
123
|
+
# 清除已有处理器:先关闭再移除,避免遗留打开的文件句柄
|
|
124
|
+
# (例如 RotatingFileHandler 的日志文件;重复配置会触发 ResourceWarning)
|
|
125
|
+
for handler in list(root_logger.handlers):
|
|
126
|
+
root_logger.removeHandler(handler)
|
|
127
|
+
with contextlib.suppress(Exception):
|
|
128
|
+
handler.close()
|
|
124
129
|
|
|
125
130
|
# 创建格式化器
|
|
126
131
|
if structured:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remote_cmd_manager
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Cross-platform SSH remote server management tool (Linux/macOS/Windows) — lightweight, zero-configuration CLI and Python API for managing servers over SSH. Supports host CRUD, batch commands, file transfer, credential encryption, async execution, and more.
|
|
5
5
|
Author-email: Vae-Scrooge <vaescrooge@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -89,28 +89,30 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
|
|
|
89
89
|
|
|
90
90
|
---
|
|
91
91
|
|
|
92
|
-
## v2.
|
|
92
|
+
## v2.4.0 Release Highlights
|
|
93
93
|
|
|
94
|
-
v2.
|
|
95
|
-
|
|
94
|
+
v2.4.0 adds an opt-in retained-output cap for batch execution and cleans up logging
|
|
95
|
+
handler resources, with default behavior and public result schemas unchanged. See the
|
|
96
96
|
[full migration notes](./CHANGELOG.md) before upgrading automated callers.
|
|
97
97
|
|
|
98
|
-
###
|
|
98
|
+
### Bounded Batch Output
|
|
99
99
|
|
|
100
|
-
-
|
|
101
|
-
-
|
|
102
|
-
- The
|
|
103
|
-
- Timed-out or cancelled SFTP sessions are closed and discarded, so a stale or desynchronized channel can never be reused.
|
|
100
|
+
- `BatchExecutor` / `AsyncBatchExecutor` accept an optional `max_output_bytes` (default `None`): `None` keeps full `stdout`/`stderr`, while a positive value caps each host's retained streams and appends a `[output truncated: N bytes omitted]` marker.
|
|
101
|
+
- Truncation is deterministic and UTF-8 byte-boundary safe, applies to `stdout` and `stderr` independently, and never changes command success/failure or exit codes.
|
|
102
|
+
- The cap bounds the retained `BatchResult`; the SSH client may still hold full output transiently during execution, so it is not a process-wide RSS limit.
|
|
104
103
|
|
|
105
|
-
###
|
|
104
|
+
### Reliability
|
|
106
105
|
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
|
|
106
|
+
- `setup_logging` now closes previous root-logger handlers before removing them, avoiding unclosed-file resource warnings when logging is reconfigured.
|
|
107
|
+
- Connection-pool lifetime wording in the documentation now matches the actual behavior: internal pools are created lazily per host and closed as soon as that host finishes (including its retries).
|
|
108
|
+
|
|
109
|
+
### Compatibility
|
|
110
|
+
|
|
111
|
+
- Default behavior is unchanged (`max_output_bytes=None` retains full output) and the public `BatchResult` / `BatchHostResult` schemas are unchanged.
|
|
110
112
|
|
|
111
113
|
## Table of Contents
|
|
112
114
|
|
|
113
|
-
- [v2.
|
|
115
|
+
- [v2.4.0 Release Highlights](#v240-release-highlights)
|
|
114
116
|
- [Why Remote CMD?](#why-remote-cmd)
|
|
115
117
|
- [Quick Start](#quick-start)
|
|
116
118
|
- [Use Cases](#use-cases)
|
|
@@ -258,7 +260,7 @@ with SSHClient(config) as client:
|
|
|
258
260
|
| **File Transfer** | Upload/download via SFTP (`remote-cmd upload/download`) |
|
|
259
261
|
| **Host Management** | CRUD with pluggable JSON or **SQLite** persistence |
|
|
260
262
|
| **Tag System** | Filter hosts by tag (e.g., `production`, `web`, `db`) |
|
|
261
|
-
| **Batch Ops** | Run commands across any host group, synchronously or asynchronously |
|
|
263
|
+
| **Batch Ops** | Run commands across any host group, synchronously or asynchronously; optional per-host retained-output cap (`max_output_bytes`) |
|
|
262
264
|
| **Async Kernel** | `AsyncSSHClient` / `AsyncConnectionPool` / `AsyncBatchExecutor` via the `[async]` extra |
|
|
263
265
|
| **Task Runner** | Track and schedule long-running remote tasks with statuses (`TaskRunner`) |
|
|
264
266
|
| **Connection Test** | Test all host SSH connections and report status |
|
|
@@ -11,6 +11,7 @@ from remote_cmd.core.async_connection_pool import AsyncConnectionPool
|
|
|
11
11
|
from remote_cmd.core.async_ssh_client import AsyncSSHClient
|
|
12
12
|
from remote_cmd.core.host import Host
|
|
13
13
|
from remote_cmd.core.ssh_client import CommandResult, ConnectionConfig
|
|
14
|
+
from remote_cmd.service._host_runner import OUTPUT_TRUNCATION_MARKER
|
|
14
15
|
from remote_cmd.service.async_batch_executor import AsyncBatchExecutor
|
|
15
16
|
from remote_cmd.service.batch_executor import BatchExecutor, BatchHostResult
|
|
16
17
|
from remote_cmd.utils.exceptions import (
|
|
@@ -18,6 +19,7 @@ from remote_cmd.utils.exceptions import (
|
|
|
18
19
|
PoolClosedError,
|
|
19
20
|
SSHAuthenticationError,
|
|
20
21
|
SSHTimeoutError,
|
|
22
|
+
ValidationError,
|
|
21
23
|
)
|
|
22
24
|
|
|
23
25
|
# ============================================================================
|
|
@@ -940,3 +942,119 @@ class TestAsyncBatchExecutorInternalPoolLifecycle:
|
|
|
940
942
|
assert FlakyAsyncClient.created[0].calls == 3
|
|
941
943
|
assert pools[0]._closed
|
|
942
944
|
assert not FlakyAsyncClient.created[0].is_connected()
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
# ============================================================================
|
|
948
|
+
# v2.4:max_output_bytes 保留输出上限(异步语义与同步对齐)
|
|
949
|
+
# ============================================================================
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _marker(omitted: int) -> str:
|
|
953
|
+
return OUTPUT_TRUNCATION_MARKER.format(omitted=omitted)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
class TestAsyncBatchExecutorOutputCap:
|
|
957
|
+
"""异步执行器 max_output_bytes 语义(默认 None = 完整输出)"""
|
|
958
|
+
|
|
959
|
+
@pytest.mark.asyncio
|
|
960
|
+
async def test_default_none_retains_full_output(self, mock_async_client_class):
|
|
961
|
+
cm, instance = mock_async_client_class
|
|
962
|
+
payload = "x" * 8192
|
|
963
|
+
instance.execute = AsyncMock(return_value=CommandResult("uptime", payload, payload, 0))
|
|
964
|
+
host = Host(name="srv1", hostname="10.0.0.1", username="admin")
|
|
965
|
+
ex = AsyncBatchExecutor(host_service=make_mock_service([host]))
|
|
966
|
+
result = await ex.execute(["srv1"], "uptime")
|
|
967
|
+
host_result = result.results["srv1"]
|
|
968
|
+
assert host_result.stdout == payload
|
|
969
|
+
assert host_result.stderr == payload
|
|
970
|
+
assert "[output truncated" not in host_result.stdout
|
|
971
|
+
|
|
972
|
+
@pytest.mark.asyncio
|
|
973
|
+
async def test_positive_cap_truncates_stdout_and_stderr(self, mock_async_client_class):
|
|
974
|
+
cm, instance = mock_async_client_class
|
|
975
|
+
instance.execute = AsyncMock(return_value=CommandResult("uptime", "o" * 500, "e" * 700, 0))
|
|
976
|
+
host = Host(name="srv1", hostname="10.0.0.1", username="admin")
|
|
977
|
+
ex = AsyncBatchExecutor(host_service=make_mock_service([host]), max_output_bytes=100)
|
|
978
|
+
result = await ex.execute(["srv1"], "uptime")
|
|
979
|
+
host_result = result.results["srv1"]
|
|
980
|
+
assert host_result.stdout == "o" * 100 + _marker(400)
|
|
981
|
+
assert host_result.stderr == "e" * 100 + _marker(600)
|
|
982
|
+
assert host_result.success is True
|
|
983
|
+
|
|
984
|
+
@pytest.mark.asyncio
|
|
985
|
+
async def test_below_and_exact_boundary_unchanged(self, mock_async_client_class):
|
|
986
|
+
cm, instance = mock_async_client_class
|
|
987
|
+
instance.execute = AsyncMock(return_value=CommandResult("uptime", "exact", "small", 0))
|
|
988
|
+
host = Host(name="srv1", hostname="10.0.0.1", username="admin")
|
|
989
|
+
|
|
990
|
+
ex = AsyncBatchExecutor(host_service=make_mock_service([host]), max_output_bytes=5)
|
|
991
|
+
result = await ex.execute(["srv1"], "uptime")
|
|
992
|
+
assert result.results["srv1"].stdout == "exact"
|
|
993
|
+
assert result.results["srv1"].stderr == "small"
|
|
994
|
+
|
|
995
|
+
ex = AsyncBatchExecutor(host_service=make_mock_service([host]), max_output_bytes=64)
|
|
996
|
+
result = await ex.execute(["srv1"], "uptime")
|
|
997
|
+
assert result.results["srv1"].stdout == "exact"
|
|
998
|
+
|
|
999
|
+
@pytest.mark.asyncio
|
|
1000
|
+
async def test_cap_preserves_failure_semantics(self, mock_async_client_class):
|
|
1001
|
+
cm, instance = mock_async_client_class
|
|
1002
|
+
instance.execute = AsyncMock(
|
|
1003
|
+
return_value=CommandResult("uptime", "x" * 1000, "e" * 1000, 7)
|
|
1004
|
+
)
|
|
1005
|
+
host = Host(name="srv1", hostname="10.0.0.1", username="admin")
|
|
1006
|
+
ex = AsyncBatchExecutor(host_service=make_mock_service([host]), max_output_bytes=16)
|
|
1007
|
+
result = await ex.execute(["srv1"], "uptime")
|
|
1008
|
+
host_result = result.results["srv1"]
|
|
1009
|
+
assert host_result.success is False
|
|
1010
|
+
assert host_result.exit_code == 7
|
|
1011
|
+
assert host_result.stdout.startswith("x" * 16)
|
|
1012
|
+
assert host_result.stderr.startswith("e" * 16)
|
|
1013
|
+
|
|
1014
|
+
@pytest.mark.parametrize("bad", [0, -1, True, False, 1.5, "64"])
|
|
1015
|
+
def test_invalid_max_output_bytes_rejected(self, bad):
|
|
1016
|
+
with pytest.raises(ValidationError, match="max_output_bytes"):
|
|
1017
|
+
AsyncBatchExecutor(host_service=MagicMock(), max_output_bytes=bad)
|
|
1018
|
+
|
|
1019
|
+
@pytest.mark.asyncio
|
|
1020
|
+
async def test_large_synthetic_output_bounded(self, mock_async_client_class):
|
|
1021
|
+
cm, instance = mock_async_client_class
|
|
1022
|
+
chunk = "z" * (256 * 1024)
|
|
1023
|
+
instance.execute = AsyncMock(return_value=CommandResult("uptime", chunk, chunk, 0))
|
|
1024
|
+
hosts = [
|
|
1025
|
+
Host(name=f"srv{i}", hostname=f"10.0.0.{i + 1}", username="admin") for i in range(10)
|
|
1026
|
+
]
|
|
1027
|
+
ex = AsyncBatchExecutor(host_service=make_mock_service(hosts), max_output_bytes=4096)
|
|
1028
|
+
result = await ex.execute([h.name for h in hosts], "uptime")
|
|
1029
|
+
assert result.success == 10
|
|
1030
|
+
retained = sum(len(r.stdout) + len(r.stderr) for r in result.results.values())
|
|
1031
|
+
assert retained <= 10 * 2 * (4096 + 64)
|
|
1032
|
+
assert retained < 10 * 2 * len(chunk) // 10
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
class TestOutputCapParity:
|
|
1036
|
+
@pytest.mark.asyncio
|
|
1037
|
+
async def test_sync_and_async_truncation_equivalent(self, mock_async_client_class):
|
|
1038
|
+
cm, instance = mock_async_client_class
|
|
1039
|
+
stdout = ("o" * 5000) + ("é" * 100)
|
|
1040
|
+
stderr = "e" * 3000
|
|
1041
|
+
instance.execute = AsyncMock(return_value=CommandResult("uptime", stdout, stderr, 7))
|
|
1042
|
+
host = Host(name="srv1", hostname="10.0.0.1", username="admin")
|
|
1043
|
+
|
|
1044
|
+
async_ex = AsyncBatchExecutor(host_service=make_mock_service([host]), max_output_bytes=257)
|
|
1045
|
+
async_result = await async_ex.execute(["srv1"], "uptime")
|
|
1046
|
+
|
|
1047
|
+
with patch("remote_cmd.service.batch_executor.SSHClient") as mock_cls:
|
|
1048
|
+
inst = MagicMock()
|
|
1049
|
+
mock_cls.return_value = inst
|
|
1050
|
+
inst.execute.return_value = CommandResult("uptime", stdout, stderr, 7)
|
|
1051
|
+
sync_ex = BatchExecutor(host_service=make_mock_service([host]), max_output_bytes=257)
|
|
1052
|
+
sync_result = sync_ex.execute(["srv1"], "uptime")
|
|
1053
|
+
|
|
1054
|
+
sync_host = sync_result.results["srv1"]
|
|
1055
|
+
async_host = async_result.results["srv1"]
|
|
1056
|
+
assert sync_host.stdout == async_host.stdout
|
|
1057
|
+
assert sync_host.stderr == async_host.stderr
|
|
1058
|
+
assert sync_host.exit_code == async_host.exit_code == 7
|
|
1059
|
+
assert sync_host.success is async_host.success is False
|
|
1060
|
+
assert "[output truncated" in sync_host.stdout
|
|
@@ -8,8 +8,13 @@ import pytest
|
|
|
8
8
|
from remote_cmd.core.host import Host
|
|
9
9
|
from remote_cmd.core.ssh_client import CommandResult
|
|
10
10
|
from remote_cmd.core.sync_connection_pool import SyncConnectionPool
|
|
11
|
+
from remote_cmd.service._host_runner import OUTPUT_TRUNCATION_MARKER
|
|
11
12
|
from remote_cmd.service.batch_executor import BatchExecutor, BatchHostResult, BatchResult
|
|
12
|
-
from remote_cmd.utils.exceptions import
|
|
13
|
+
from remote_cmd.utils.exceptions import (
|
|
14
|
+
CredentialError,
|
|
15
|
+
SSHAuthenticationError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
13
18
|
|
|
14
19
|
|
|
15
20
|
def make_mock_service(hosts: list):
|
|
@@ -847,3 +852,97 @@ class TestBatchExecutorInternalPoolLifecycle:
|
|
|
847
852
|
assert AuthFailClient.created[0].calls == 1
|
|
848
853
|
assert pools[0]._closed
|
|
849
854
|
assert not AuthFailClient.created[0].is_connected()
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
# ============================================================================
|
|
858
|
+
# v2.4:max_output_bytes 保留输出上限
|
|
859
|
+
# ============================================================================
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _marker(omitted: int) -> str:
|
|
863
|
+
return OUTPUT_TRUNCATION_MARKER.format(omitted=omitted)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
class TestBatchExecutorOutputCap:
|
|
867
|
+
"""同步执行器 max_output_bytes 语义(默认 None = 完整输出)"""
|
|
868
|
+
|
|
869
|
+
@staticmethod
|
|
870
|
+
def _run(stdout="", stderr="", exit_code=0, max_output_bytes=None, hosts=1):
|
|
871
|
+
host_list = [
|
|
872
|
+
Host(name=f"srv{i}", hostname=f"10.0.0.{i + 1}", username="admin") for i in range(hosts)
|
|
873
|
+
]
|
|
874
|
+
service = make_mock_service(host_list)
|
|
875
|
+
with patch("remote_cmd.service.batch_executor.SSHClient") as mock_cls:
|
|
876
|
+
inst = MagicMock()
|
|
877
|
+
mock_cls.return_value = inst
|
|
878
|
+
inst.execute.return_value = CommandResult("true", stdout, stderr, exit_code)
|
|
879
|
+
executor = BatchExecutor(host_service=service, max_output_bytes=max_output_bytes)
|
|
880
|
+
return executor.execute([h.name for h in host_list], "true")
|
|
881
|
+
|
|
882
|
+
def test_default_none_retains_full_output(self):
|
|
883
|
+
payload = "x" * 8192
|
|
884
|
+
result = self._run(stdout=payload, stderr=payload)
|
|
885
|
+
host_result = result.results["srv0"]
|
|
886
|
+
assert host_result.stdout == payload
|
|
887
|
+
assert host_result.stderr == payload
|
|
888
|
+
assert "[output truncated" not in host_result.stdout
|
|
889
|
+
|
|
890
|
+
def test_positive_cap_truncates_stdout(self):
|
|
891
|
+
result = self._run(stdout="x" * 1000, max_output_bytes=64)
|
|
892
|
+
host_result = result.results["srv0"]
|
|
893
|
+
assert host_result.stdout == "x" * 64 + _marker(936)
|
|
894
|
+
assert host_result.stderr == ""
|
|
895
|
+
assert host_result.success is True
|
|
896
|
+
|
|
897
|
+
def test_positive_cap_truncates_stderr(self):
|
|
898
|
+
result = self._run(stderr="e" * 500, max_output_bytes=100)
|
|
899
|
+
host_result = result.results["srv0"]
|
|
900
|
+
assert host_result.stderr == "e" * 100 + _marker(400)
|
|
901
|
+
assert host_result.stdout == ""
|
|
902
|
+
|
|
903
|
+
def test_output_below_limit_unchanged(self):
|
|
904
|
+
result = self._run(stdout="small", stderr="tiny", max_output_bytes=1024)
|
|
905
|
+
host_result = result.results["srv0"]
|
|
906
|
+
assert host_result.stdout == "small"
|
|
907
|
+
assert host_result.stderr == "tiny"
|
|
908
|
+
assert "[output truncated" not in host_result.stdout + host_result.stderr
|
|
909
|
+
|
|
910
|
+
def test_exact_boundary_unchanged(self):
|
|
911
|
+
payload = "x" * 64
|
|
912
|
+
result = self._run(stdout=payload, stderr=payload, max_output_bytes=64)
|
|
913
|
+
host_result = result.results["srv0"]
|
|
914
|
+
assert host_result.stdout == payload
|
|
915
|
+
assert host_result.stderr == payload
|
|
916
|
+
|
|
917
|
+
def test_unicode_boundary_not_split(self):
|
|
918
|
+
# "é" 为 2 字节;上限 5 字节时只能完整保留 2 个字符(保留 4 字节)
|
|
919
|
+
result = self._run(stdout="é" * 10, max_output_bytes=5)
|
|
920
|
+
out = result.results["srv0"].stdout
|
|
921
|
+
assert out.startswith("é" * 2)
|
|
922
|
+
assert "é" * 3 not in out
|
|
923
|
+
assert "\ufffd" not in out
|
|
924
|
+
assert out.endswith(_marker(20 - 4))
|
|
925
|
+
|
|
926
|
+
def test_cap_preserves_success_and_failure_semantics(self):
|
|
927
|
+
ok = self._run(stdout="x" * 1000, exit_code=0, max_output_bytes=10)
|
|
928
|
+
assert ok.results["srv0"].success is True
|
|
929
|
+
assert ok.results["srv0"].exit_code == 0
|
|
930
|
+
|
|
931
|
+
bad = self._run(stdout="x" * 1000, exit_code=1, max_output_bytes=10)
|
|
932
|
+
assert bad.results["srv0"].success is False
|
|
933
|
+
assert bad.results["srv0"].exit_code == 1
|
|
934
|
+
|
|
935
|
+
@pytest.mark.parametrize("bad", [0, -1, True, False, 1.5, "64"])
|
|
936
|
+
def test_invalid_max_output_bytes_rejected(self, bad):
|
|
937
|
+
with pytest.raises(ValidationError, match="max_output_bytes"):
|
|
938
|
+
BatchExecutor(host_service=MagicMock(), max_output_bytes=bad)
|
|
939
|
+
|
|
940
|
+
@patch("remote_cmd.service.batch_executor.SSHClient")
|
|
941
|
+
def test_large_synthetic_output_bounded(self, _mock_ssh_class):
|
|
942
|
+
chunk = "z" * (256 * 1024)
|
|
943
|
+
result = self._run(stdout=chunk, stderr=chunk, hosts=20, max_output_bytes=4096)
|
|
944
|
+
assert result.success == 20
|
|
945
|
+
retained = sum(len(r.stdout) + len(r.stderr) for r in result.results.values())
|
|
946
|
+
marker_allowance = 64 # 标记本身很小,给足空间
|
|
947
|
+
assert retained <= 20 * 2 * (4096 + marker_allowance)
|
|
948
|
+
assert retained < 20 * 2 * len(chunk) // 10
|
|
@@ -194,6 +194,20 @@ class TestSetupLogging:
|
|
|
194
194
|
second = set(logging.getLogger().handlers)
|
|
195
195
|
assert not (first & second)
|
|
196
196
|
|
|
197
|
+
def test_previous_file_handler_closed_before_removal(self, tmp_path):
|
|
198
|
+
"""测试:重新配置日志时旧的文件处理器被关闭(无未关闭句柄)"""
|
|
199
|
+
root = logging.getLogger()
|
|
200
|
+
try:
|
|
201
|
+
setup_logging(log_file=str(tmp_path / "a.log"))
|
|
202
|
+
first = [h for h in root.handlers if isinstance(h, logging.FileHandler)][-1]
|
|
203
|
+
assert first.stream is not None
|
|
204
|
+
|
|
205
|
+
setup_logging(log_file=str(tmp_path / "b.log"))
|
|
206
|
+
assert first.stream is None # close() 释放底层文件流
|
|
207
|
+
assert first not in root.handlers
|
|
208
|
+
finally:
|
|
209
|
+
setup_logging()
|
|
210
|
+
|
|
197
211
|
|
|
198
212
|
class TestLoggerAdapter:
|
|
199
213
|
"""带上下文日志适配器测试"""
|
|
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
|
{remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/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-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/sync_connection_pool.py
RENAMED
|
File without changes
|
|
File without changes
|
{remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/repository/host_repository.py
RENAMED
|
File without changes
|
{remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/repository/json_host_repository.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/credential_provider.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
|
|
File without changes
|
{remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/requires.txt
RENAMED
|
File without changes
|
{remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/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
|
{remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/integration/test_ssh_connection.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
|
|
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
|