remote-cmd-manager 2.6.0__tar.gz → 2.7.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.6.0/remote_cmd_manager.egg-info → remote_cmd_manager-2.7.0}/PKG-INFO +21 -17
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/README.md +20 -16
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/pyproject.toml +2 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/__init__.py +9 -8
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/_version.py +1 -1
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/async_connection_pool.py +3 -1
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/budget.py +116 -44
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/sync_connection_pool.py +3 -1
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/repository/sqlite_host_repository.py +40 -4
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/credential_provider.py +26 -3
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/host_service.py +3 -3
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/utils/crypto.py +7 -2
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0/remote_cmd_manager.egg-info}/PKG-INFO +21 -17
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd_manager.egg-info/SOURCES.txt +3 -1
- remote_cmd_manager-2.7.0/tests/performance/test_paramiko_threads.py +174 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_connection_budget.py +80 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_credential_provider.py +69 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_crypto.py +28 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_host_service.py +20 -1
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_keyring_provider.py +5 -3
- remote_cmd_manager-2.7.0/tests/test_package_import.py +59 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_sqlite_repository.py +42 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_ssh_client.py +63 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/LICENSE +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/MANIFEST.in +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/config.example.yaml +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/examples/basic_usage.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/examples/deploy_script.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/examples/nginx_batch_update.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/examples/system_health_check.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/api/__init__.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/api/host_manager.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/cli/__init__.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/cli/main.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/__init__.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/async_ssh_client.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/host.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/host_manager.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/pool_policy.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/ssh_client.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/repository/__init__.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/repository/host_repository.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/repository/json_host_repository.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/__init__.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/_host_runner.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/_pool_policy.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/_types.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/async_batch_executor.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/batch_executor.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/retry_policy.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/ssh_service.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/storage_factory.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/task_runner.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/utils/__init__.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/utils/config.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/utils/credential_guard.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/utils/exceptions.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/utils/logging_utils.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd_manager.egg-info/dependency_links.txt +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd_manager.egg-info/entry_points.txt +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd_manager.egg-info/requires.txt +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd_manager.egg-info/top_level.txt +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/requirements.txt +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/setup.cfg +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/__init__.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/conftest.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/integration/conftest.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/integration/test_ssh_connection.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/performance/__init__.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/performance/conftest.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/performance/test_benchmarks.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_async_batch_executor.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_async_ssh_client.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_batch_executor.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_cli.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_config.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_exceptions.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_host.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_host_manager.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_logging_utils.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_output_policy.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_pool_policy.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_release_metadata.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_repository.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_retry_policy.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_ssh_service.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_storage_factory.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/tests/test_sync_connection_pool.py +0 -0
- {remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.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.7.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,32 +89,36 @@ 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.7.0 Release Highlights
|
|
93
93
|
|
|
94
|
-
v2.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
backward compatible. See the
|
|
94
|
+
v2.7.0 is a runtime-efficiency and error-semantics release: credential-layer
|
|
95
|
+
exceptions are narrowed so real bugs surface, SQLite no longer truncates its WAL
|
|
96
|
+
on every write, and the global connection budget waits on a true async queue
|
|
97
|
+
instead of polling. Public APIs remain backward compatible. See the
|
|
98
|
+
[full migration notes](./CHANGELOG.md).
|
|
98
99
|
|
|
99
|
-
###
|
|
100
|
+
### Error Semantics
|
|
100
101
|
|
|
101
|
-
-
|
|
102
|
-
-
|
|
103
|
-
-
|
|
102
|
+
- Keyring, encryption, and host-service decrypt paths now catch only expected failure classes; programming errors propagate instead of being silently swallowed.
|
|
103
|
+
- Background pool monitor loops keep their catch-all guard but log full stack traces.
|
|
104
|
+
- Optional `asyncssh` detection uses `find_spec`, so a defect in the async modules themselves can no longer masquerade as "dependency not installed".
|
|
104
105
|
|
|
105
|
-
###
|
|
106
|
+
### SQLite WAL Efficiency
|
|
106
107
|
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
- Optional `acquire_timeout` raises the transient `BudgetTimeoutError`; the default (`None`) keeps unlimited behavior.
|
|
108
|
+
- `flush()` now runs a **PASSIVE** checkpoint instead of `TRUNCATE` — no more writer waits or WAL rewrites on every host add/update/remove.
|
|
109
|
+
- New explicit `repo.checkpoint("TRUNCATE")` (also `PASSIVE`/`FULL`/`RESTART`) for deliberate WAL compaction.
|
|
110
110
|
|
|
111
|
-
###
|
|
111
|
+
### Connection Budget
|
|
112
112
|
|
|
113
|
-
-
|
|
113
|
+
- `ConnectionBudget.acquire_async()` now waits on a real queue (`asyncio.Future` woken via `call_soon_threadsafe`), removing the v2.6 polling loop; cancellation-safe and FIFO among async waiters.
|
|
114
|
+
|
|
115
|
+
### Benchmark Data (P2.4)
|
|
116
|
+
|
|
117
|
+
- The sync Paramiko path costs ~100 µs per command in thread create/join and peaks at ≈ 3× sustained concurrency in live threads. A centralized channel-reader rewrite is a v2.7.x decision based on these measurements.
|
|
114
118
|
|
|
115
119
|
## Table of Contents
|
|
116
120
|
|
|
117
|
-
- [v2.
|
|
121
|
+
- [v2.7.0 Release Highlights](#v270-release-highlights)
|
|
118
122
|
- [Why Remote CMD?](#why-remote-cmd)
|
|
119
123
|
- [Quick Start](#quick-start)
|
|
120
124
|
- [Use Cases](#use-cases)
|
|
@@ -36,32 +36,36 @@ 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.7.0 Release Highlights
|
|
40
40
|
|
|
41
|
-
v2.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
backward compatible. See the
|
|
41
|
+
v2.7.0 is a runtime-efficiency and error-semantics release: credential-layer
|
|
42
|
+
exceptions are narrowed so real bugs surface, SQLite no longer truncates its WAL
|
|
43
|
+
on every write, and the global connection budget waits on a true async queue
|
|
44
|
+
instead of polling. Public APIs remain backward compatible. See the
|
|
45
|
+
[full migration notes](./CHANGELOG.md).
|
|
45
46
|
|
|
46
|
-
###
|
|
47
|
+
### Error Semantics
|
|
47
48
|
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
49
|
+
- Keyring, encryption, and host-service decrypt paths now catch only expected failure classes; programming errors propagate instead of being silently swallowed.
|
|
50
|
+
- Background pool monitor loops keep their catch-all guard but log full stack traces.
|
|
51
|
+
- Optional `asyncssh` detection uses `find_spec`, so a defect in the async modules themselves can no longer masquerade as "dependency not installed".
|
|
51
52
|
|
|
52
|
-
###
|
|
53
|
+
### SQLite WAL Efficiency
|
|
53
54
|
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
- Optional `acquire_timeout` raises the transient `BudgetTimeoutError`; the default (`None`) keeps unlimited behavior.
|
|
55
|
+
- `flush()` now runs a **PASSIVE** checkpoint instead of `TRUNCATE` — no more writer waits or WAL rewrites on every host add/update/remove.
|
|
56
|
+
- New explicit `repo.checkpoint("TRUNCATE")` (also `PASSIVE`/`FULL`/`RESTART`) for deliberate WAL compaction.
|
|
57
57
|
|
|
58
|
-
###
|
|
58
|
+
### Connection Budget
|
|
59
59
|
|
|
60
|
-
-
|
|
60
|
+
- `ConnectionBudget.acquire_async()` now waits on a real queue (`asyncio.Future` woken via `call_soon_threadsafe`), removing the v2.6 polling loop; cancellation-safe and FIFO among async waiters.
|
|
61
|
+
|
|
62
|
+
### Benchmark Data (P2.4)
|
|
63
|
+
|
|
64
|
+
- The sync Paramiko path costs ~100 µs per command in thread create/join and peaks at ≈ 3× sustained concurrency in live threads. A centralized channel-reader rewrite is a v2.7.x decision based on these measurements.
|
|
61
65
|
|
|
62
66
|
## Table of Contents
|
|
63
67
|
|
|
64
|
-
- [v2.
|
|
68
|
+
- [v2.7.0 Release Highlights](#v270-release-highlights)
|
|
65
69
|
- [Why Remote CMD?](#why-remote-cmd)
|
|
66
70
|
- [Quick Start](#quick-start)
|
|
67
71
|
- [Use Cases](#use-cases)
|
|
@@ -122,6 +122,8 @@ ignore = [
|
|
|
122
122
|
"__init__.py" = ["E402"]
|
|
123
123
|
# 测试中 pytest fixture 参数未直接引用属于正常模式(如 patch 装饰器触发副作用)
|
|
124
124
|
"tests/test_async_ssh_client.py" = ["ARG002"]
|
|
125
|
+
# mock_paramiko fixture 以 patch 副作用生效,线程模型测试不直接引用其返回值
|
|
126
|
+
"tests/test_ssh_client.py" = ["ARG002"]
|
|
125
127
|
"tests/test_async_batch_executor.py" = ["ARG001", "ARG002"]
|
|
126
128
|
# 连接池测试中 patched_client fixture 触发 patch 副作用,factory 保留 cfg 参数以匹配真实签名
|
|
127
129
|
"tests/test_sync_connection_pool.py" = ["ARG001", "ARG002"]
|
|
@@ -48,7 +48,7 @@ Remote CMD - SSH 远程服务器管理工具
|
|
|
48
48
|
- 文档: 参见 docs/ 目录
|
|
49
49
|
|
|
50
50
|
Author: Vae-Scrooge
|
|
51
|
-
Version: 2.
|
|
51
|
+
Version: 2.7.0(单一真相源见 remote_cmd._version)
|
|
52
52
|
License: MIT
|
|
53
53
|
"""
|
|
54
54
|
|
|
@@ -59,21 +59,22 @@ __author__ = "Vae-Scrooge"
|
|
|
59
59
|
__email__ = "vae-scrooge@example.com"
|
|
60
60
|
__license__ = "MIT"
|
|
61
61
|
|
|
62
|
+
import importlib.util
|
|
62
63
|
import logging
|
|
63
64
|
|
|
64
65
|
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
65
66
|
|
|
66
|
-
# 异步原生模块依赖 asyncssh(optional extra [async]
|
|
67
|
-
#
|
|
68
|
-
|
|
67
|
+
# 异步原生模块依赖 asyncssh(optional extra [async])。v2.7(P2.2)起用
|
|
68
|
+
# find_spec 显式探测可选依赖:仅当 asyncssh 缺失时才降级;若 asyncssh 已
|
|
69
|
+
# 安装而我们自己的异步模块 import 失败(代码缺陷/依赖损坏),import
|
|
70
|
+
# 错误会向上传播,不再被静默误判为"未安装 asyncssh"。
|
|
71
|
+
_HAS_ASYNC = importlib.util.find_spec("asyncssh") is not None
|
|
72
|
+
|
|
73
|
+
if _HAS_ASYNC:
|
|
69
74
|
from remote_cmd.core.async_connection_pool import AsyncConnectionPool
|
|
70
75
|
from remote_cmd.core.async_ssh_client import AsyncSSHClient
|
|
71
76
|
from remote_cmd.service.async_batch_executor import AsyncBatchExecutor
|
|
72
77
|
|
|
73
|
-
_HAS_ASYNC = True
|
|
74
|
-
except ImportError: # pragma: no cover - 依赖 asyncssh,未安装时不导出异步符号
|
|
75
|
-
_HAS_ASYNC = False
|
|
76
|
-
|
|
77
78
|
from remote_cmd.api.host_manager import HostManager
|
|
78
79
|
from remote_cmd.core.budget import ConnectionBudget
|
|
79
80
|
from remote_cmd.core.host import Host
|
{remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/async_connection_pool.py
RENAMED
|
@@ -278,7 +278,9 @@ class AsyncConnectionPool:
|
|
|
278
278
|
except asyncio.CancelledError:
|
|
279
279
|
break
|
|
280
280
|
except Exception: # noqa: BLE001
|
|
281
|
-
|
|
281
|
+
# 后台清理任务必须吞掉一切非取消异常以保持存活(单次清理
|
|
282
|
+
# 失败不应终止后续周期);保留完整堆栈便于定位
|
|
283
|
+
logger.warning("connection pool monitor error", exc_info=True)
|
|
282
284
|
|
|
283
285
|
async def _cleanup_expired(self) -> None:
|
|
284
286
|
now = time.time()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
全局连接预算(v2.6 P2.5
|
|
2
|
+
全局连接预算(v2.6 P2.5;v2.7 真异步等待队列)
|
|
3
3
|
|
|
4
4
|
用于在**多个连接池 / 多个执行器**之间封顶进程内同时存活的 SSH 连接数,
|
|
5
5
|
避免大规模 fleet 场景下 per-pool 上限叠加导致的 fd/socket 耗尽:
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
池 A(max=10) + 池 B(max=10) + … 无预算时可无限叠加
|
|
8
8
|
预算(max=50) 全局硬上限 50 条存活连接
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
预算语义(存活连接预算):
|
|
11
11
|
- 预算单位 = **存活连接**(含池中空闲连接)。
|
|
12
12
|
- 获取时机:连接创建(``connect``)前;释放时机:连接断开 / 关闭 /
|
|
13
13
|
被清理 / 池 ``close_all``。
|
|
@@ -19,10 +19,17 @@
|
|
|
19
19
|
- 异步::meth:`acquire_async` / :meth:`release_async` /
|
|
20
20
|
:meth:`acquire_context_async`
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
``threading.
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
同一实例可被同步与异步执行器共享:容量与等待队列由单个
|
|
23
|
+
``threading.Condition`` 保护;异步等待者注册 ``asyncio.Future``,
|
|
24
|
+
释放时通过 ``loop.call_soon_threadsafe`` 精准唤醒(v2.7 P2.5 优化:
|
|
25
|
+
取代 v2.6 的 10→50ms 轮询,无空转 wakeup)。
|
|
26
|
+
|
|
27
|
+
唤醒语义(condition-variable 模式):
|
|
28
|
+
- 唤醒只是"提示"而非预留:被唤醒者需重新竞争容量;释放方不预占槽位。
|
|
29
|
+
- 异步等待者之间 FIFO;同步等待者之间 FIFO;跨类型顺序不作保证。
|
|
30
|
+
- 等待者超时/取消时会从队列移除;若唤醒权已派发给它,
|
|
31
|
+
``_resolve_waiter`` 检测到 future 已完成会补唤醒下一个等待者,
|
|
32
|
+
不会因取消竞态丢失唤醒。
|
|
26
33
|
|
|
27
34
|
超时:``acquire_timeout=None``(默认)无限等待;配置正整数/浮点秒数时
|
|
28
35
|
超时抛出 :class:`~remote_cmd.utils.exceptions.BudgetTimeoutError`。
|
|
@@ -44,14 +51,11 @@ import asyncio
|
|
|
44
51
|
import contextlib
|
|
45
52
|
import threading
|
|
46
53
|
import time
|
|
47
|
-
from
|
|
54
|
+
from collections import deque
|
|
55
|
+
from typing import Any, AsyncIterator, Deque, Iterator, Optional
|
|
48
56
|
|
|
49
57
|
from remote_cmd.utils.exceptions import BudgetTimeoutError, ValidationError
|
|
50
58
|
|
|
51
|
-
# 异步轮询等待参数(秒):起步 10ms,指数退避至 50ms 上限
|
|
52
|
-
_DEFAULT_POLL_INTERVAL = 0.01
|
|
53
|
-
_MAX_POLL_INTERVAL = 0.05
|
|
54
|
-
|
|
55
59
|
|
|
56
60
|
class ConnectionBudget:
|
|
57
61
|
"""全局连接预算(同步 / 异步共用)。
|
|
@@ -89,12 +93,13 @@ class ConnectionBudget:
|
|
|
89
93
|
|
|
90
94
|
self._max = max_connections
|
|
91
95
|
self._acquire_timeout = acquire_timeout
|
|
92
|
-
# 单一底层信号量:同步/异步共享同一个全局上限
|
|
93
|
-
self._semaphore = threading.BoundedSemaphore(max_connections)
|
|
94
|
-
self._lock = threading.Lock()
|
|
95
96
|
|
|
96
|
-
#
|
|
97
|
+
# 单一互斥 + 条件变量:保护容量计数与等待队列(同步/异步共享)
|
|
98
|
+
self._cond = threading.Condition(threading.Lock())
|
|
97
99
|
self._in_use = 0
|
|
100
|
+
self._async_waiters: Deque[asyncio.Future[None]] = deque()
|
|
101
|
+
|
|
102
|
+
# 指标
|
|
98
103
|
self._total_acquired = 0
|
|
99
104
|
self._total_released = 0
|
|
100
105
|
self._total_timeouts = 0
|
|
@@ -114,7 +119,7 @@ class ConnectionBudget:
|
|
|
114
119
|
|
|
115
120
|
def get_metrics(self) -> dict[str, Any]:
|
|
116
121
|
"""预算指标快照。"""
|
|
117
|
-
with self.
|
|
122
|
+
with self._cond:
|
|
118
123
|
return {
|
|
119
124
|
"max_connections": self._max,
|
|
120
125
|
"in_use": self._in_use,
|
|
@@ -133,31 +138,37 @@ class ConnectionBudget:
|
|
|
133
138
|
Raises:
|
|
134
139
|
BudgetTimeoutError: 等待超过 ``acquire_timeout``
|
|
135
140
|
"""
|
|
136
|
-
|
|
137
|
-
if
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
141
|
+
timeout = self._acquire_timeout
|
|
142
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
143
|
+
with self._cond:
|
|
144
|
+
while self._in_use >= self._max:
|
|
145
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
146
|
+
if remaining is not None and remaining <= 0:
|
|
147
|
+
self._total_timeouts += 1
|
|
148
|
+
# 超时者退出前让出一次唤醒权,避免其他同步等待者
|
|
149
|
+
# 因槽位空闲却无人被通知而饥饿
|
|
150
|
+
self._cond.notify()
|
|
151
|
+
raise BudgetTimeoutError(
|
|
152
|
+
"connection budget exhausted: "
|
|
153
|
+
f"waited {timeout}s for a slot "
|
|
154
|
+
f"(max_connections={self._max})"
|
|
155
|
+
)
|
|
156
|
+
self._cond.wait(remaining)
|
|
146
157
|
self._in_use += 1
|
|
147
158
|
self._total_acquired += 1
|
|
148
159
|
|
|
149
160
|
def release(self) -> None:
|
|
150
|
-
"""
|
|
161
|
+
"""释放一个连接预算槽位并唤醒一个等待者。
|
|
151
162
|
|
|
152
163
|
Raises:
|
|
153
|
-
ValueError:
|
|
164
|
+
ValueError: 释放次数超过获取次数
|
|
154
165
|
"""
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
with self._lock:
|
|
166
|
+
with self._cond:
|
|
167
|
+
if self._in_use <= 0:
|
|
168
|
+
raise ValueError("release without a matching acquire")
|
|
159
169
|
self._in_use -= 1
|
|
160
170
|
self._total_released += 1
|
|
171
|
+
self._wake_next_locked()
|
|
161
172
|
|
|
162
173
|
@contextlib.contextmanager
|
|
163
174
|
def acquire_context(self) -> Iterator[None]:
|
|
@@ -172,27 +183,51 @@ class ConnectionBudget:
|
|
|
172
183
|
# 异步接口
|
|
173
184
|
# ------------------------------------------------------------------
|
|
174
185
|
async def acquire_async(self) -> None:
|
|
175
|
-
"""获取一个连接预算槽位(asyncio
|
|
186
|
+
"""获取一个连接预算槽位(asyncio 原生等待,受 ``acquire_timeout`` 约束)。
|
|
176
187
|
|
|
177
|
-
|
|
188
|
+
通过注册 ``asyncio.Future`` 等待释放方唤醒,不使用轮询。
|
|
178
189
|
"""
|
|
179
190
|
timeout = self._acquire_timeout
|
|
180
191
|
deadline = None if timeout is None else time.monotonic() + timeout
|
|
181
|
-
|
|
182
|
-
while
|
|
183
|
-
|
|
184
|
-
|
|
192
|
+
loop = asyncio.get_running_loop()
|
|
193
|
+
while True:
|
|
194
|
+
with self._cond:
|
|
195
|
+
if self._in_use < self._max:
|
|
196
|
+
self._in_use += 1
|
|
197
|
+
self._total_acquired += 1
|
|
198
|
+
return
|
|
199
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
200
|
+
if remaining is not None and remaining <= 0:
|
|
201
|
+
self._total_timeouts += 1
|
|
202
|
+
self._cond.notify()
|
|
203
|
+
raise BudgetTimeoutError(
|
|
204
|
+
"connection budget exhausted: "
|
|
205
|
+
f"waited {timeout}s for a slot "
|
|
206
|
+
f"(max_connections={self._max})"
|
|
207
|
+
)
|
|
208
|
+
fut: asyncio.Future[None] = loop.create_future()
|
|
209
|
+
self._async_waiters.append(fut)
|
|
210
|
+
try:
|
|
211
|
+
if remaining is None:
|
|
212
|
+
await fut
|
|
213
|
+
else:
|
|
214
|
+
await asyncio.wait_for(fut, remaining)
|
|
215
|
+
except asyncio.TimeoutError:
|
|
216
|
+
with self._cond:
|
|
217
|
+
self._discard_waiter_locked(fut)
|
|
185
218
|
self._total_timeouts += 1
|
|
219
|
+
# 若唤醒权已被派发给本 future(弹出后 resolve 前超时),
|
|
220
|
+
# _resolve_waiter 会发现 fut 已完成并补唤醒下一个等待者
|
|
186
221
|
raise BudgetTimeoutError(
|
|
187
222
|
"connection budget exhausted: "
|
|
188
223
|
f"waited {timeout}s for a slot "
|
|
189
224
|
f"(max_connections={self._max})"
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
225
|
+
) from None
|
|
226
|
+
except asyncio.CancelledError:
|
|
227
|
+
with self._cond:
|
|
228
|
+
self._discard_waiter_locked(fut)
|
|
229
|
+
raise
|
|
230
|
+
# 被唤醒后回到循环顶部重新竞争容量(唤醒不预留槽位)
|
|
196
231
|
|
|
197
232
|
async def release_async(self) -> None:
|
|
198
233
|
"""释放一个连接预算槽位(异步接口;与 :meth:`release` 等价)。"""
|
|
@@ -207,5 +242,42 @@ class ConnectionBudget:
|
|
|
207
242
|
finally:
|
|
208
243
|
await self.release_async()
|
|
209
244
|
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# 内部:等待队列
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
def _discard_waiter_locked(self, fut: asyncio.Future[None]) -> None:
|
|
249
|
+
"""从异步等待队列移除 future(不存在则忽略)。须持锁调用。"""
|
|
250
|
+
with contextlib.suppress(ValueError):
|
|
251
|
+
self._async_waiters.remove(fut)
|
|
252
|
+
|
|
253
|
+
def _wake_next_locked(self) -> None:
|
|
254
|
+
"""唤醒一个等待者(异步优先 FIFO,否则通知一个同步等待者)。
|
|
255
|
+
|
|
256
|
+
唤醒不预留槽位;被唤醒者会重新检查容量。须持锁调用。
|
|
257
|
+
"""
|
|
258
|
+
while self._async_waiters:
|
|
259
|
+
fut = self._async_waiters.popleft()
|
|
260
|
+
if fut.done():
|
|
261
|
+
continue
|
|
262
|
+
loop = fut.get_loop()
|
|
263
|
+
if loop.is_closed():
|
|
264
|
+
continue
|
|
265
|
+
try:
|
|
266
|
+
loop.call_soon_threadsafe(self._resolve_waiter, fut)
|
|
267
|
+
except RuntimeError:
|
|
268
|
+
# 事件循环正在关闭:跳过该等待者,尝试下一个
|
|
269
|
+
continue
|
|
270
|
+
return
|
|
271
|
+
self._cond.notify()
|
|
272
|
+
|
|
273
|
+
def _resolve_waiter(self, fut: asyncio.Future[None]) -> None:
|
|
274
|
+
"""在事件循环线程内完成 future(或补唤醒下一个等待者)。"""
|
|
275
|
+
if fut.done():
|
|
276
|
+
# 等待者已超时/取消:唤醒权未被消费,补唤醒下一个
|
|
277
|
+
with self._cond:
|
|
278
|
+
self._wake_next_locked()
|
|
279
|
+
return
|
|
280
|
+
fut.set_result(None)
|
|
281
|
+
|
|
210
282
|
|
|
211
283
|
__all__ = ["ConnectionBudget"]
|
{remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/sync_connection_pool.py
RENAMED
|
@@ -279,7 +279,9 @@ class SyncConnectionPool:
|
|
|
279
279
|
break
|
|
280
280
|
self._cleanup_expired()
|
|
281
281
|
except Exception: # noqa: BLE001
|
|
282
|
-
|
|
282
|
+
# 后台清理线程必须吞掉一切异常以保持存活(守护线程;
|
|
283
|
+
# 单次清理失败不应终止后续周期);保留完整堆栈便于定位
|
|
284
|
+
logger.warning("connection pool monitor error", exc_info=True)
|
|
283
285
|
|
|
284
286
|
def _cleanup_expired(self) -> None:
|
|
285
287
|
now = time.time()
|
|
@@ -29,7 +29,11 @@ from remote_cmd.core.host import Host
|
|
|
29
29
|
from remote_cmd.repository.host_repository import HostRepository
|
|
30
30
|
from remote_cmd.utils.credential_guard import PasswordGuard, is_plaintext_password
|
|
31
31
|
from remote_cmd.utils.crypto import CredentialEncryption
|
|
32
|
-
from remote_cmd.utils.exceptions import
|
|
32
|
+
from remote_cmd.utils.exceptions import (
|
|
33
|
+
CredentialError,
|
|
34
|
+
PlaintextCredentialWarning,
|
|
35
|
+
ValidationError,
|
|
36
|
+
)
|
|
33
37
|
|
|
34
38
|
logger = logging.getLogger(__name__)
|
|
35
39
|
|
|
@@ -70,6 +74,9 @@ CREATE TABLE IF NOT EXISTS meta (
|
|
|
70
74
|
# 写锁等待上限(毫秒):多进程并发写同一数据库时,等待对方短事务提交/回滚
|
|
71
75
|
DEFAULT_BUSY_TIMEOUT_MS = 5000
|
|
72
76
|
|
|
77
|
+
# 显式 checkpoint 允许的模式白名单(防 SQL 注入;SQLite wal_checkpoint 模式)
|
|
78
|
+
CHECKPOINT_MODES = frozenset({"PASSIVE", "FULL", "RESTART", "TRUNCATE"})
|
|
79
|
+
|
|
73
80
|
|
|
74
81
|
class SqliteHostRepository(HostRepository):
|
|
75
82
|
"""
|
|
@@ -384,12 +391,41 @@ class SqliteHostRepository(HostRepository):
|
|
|
384
391
|
return row["cnt"] if row else 0
|
|
385
392
|
|
|
386
393
|
def flush(self) -> None:
|
|
394
|
+
"""轻量刷新:执行一次 PASSIVE WAL checkpoint(v2.7 P2.3)。
|
|
395
|
+
|
|
396
|
+
SQLite 写入即时生效,本方法是与 JsonHostRepository 对齐的接口。
|
|
397
|
+
|
|
398
|
+
v2.7 起不再在每次 flush 执行 ``wal_checkpoint(TRUNCATE)``:
|
|
399
|
+
HostService 的 add/update/remove 每次都会调用 flush,TRUNCATE 会
|
|
400
|
+
等待所有读者并在 checkpoint 后截断 WAL 文件,造成写放大与读阻塞
|
|
401
|
+
风险。PASSIVE 不等待、不截断,仅将可安全写回的帧写回数据库;
|
|
402
|
+
WAL 增长由 SQLite 自动 checkpoint(默认 1000 页)控制。
|
|
403
|
+
需要主动压缩 WAL 时请调用 :meth:`checkpoint`。
|
|
387
404
|
"""
|
|
388
|
-
|
|
389
|
-
|
|
405
|
+
with self._lock, self._txn() as conn:
|
|
406
|
+
conn.execute("PRAGMA wal_checkpoint(PASSIVE);")
|
|
407
|
+
|
|
408
|
+
def checkpoint(self, mode: str = "TRUNCATE") -> None:
|
|
409
|
+
"""执行显式 WAL checkpoint(v2.7 P2.3),用于按需压缩 WAL 文件。
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
mode: checkpoint 模式(大小写不敏感):
|
|
413
|
+
- ``PASSIVE``:不等待读者、不截断(与 flush 相同)
|
|
414
|
+
- ``FULL``:等待所有读者完成
|
|
415
|
+
- ``RESTART``:等待读者完成后重启 WAL 日志
|
|
416
|
+
- ``TRUNCATE``(默认):等待读者完成后截断 WAL 文件
|
|
417
|
+
|
|
418
|
+
Raises:
|
|
419
|
+
ValidationError: mode 不在白名单内(防 SQL 注入)
|
|
390
420
|
"""
|
|
421
|
+
normalized = mode.strip().upper()
|
|
422
|
+
if normalized not in CHECKPOINT_MODES:
|
|
423
|
+
raise ValidationError(
|
|
424
|
+
f"unsupported checkpoint mode: {mode!r} "
|
|
425
|
+
f"(expected one of: {', '.join(sorted(CHECKPOINT_MODES))})"
|
|
426
|
+
)
|
|
391
427
|
with self._lock, self._txn() as conn:
|
|
392
|
-
conn.execute("PRAGMA wal_checkpoint(
|
|
428
|
+
conn.execute(f"PRAGMA wal_checkpoint({normalized});")
|
|
393
429
|
|
|
394
430
|
# ========================================================================
|
|
395
431
|
# 扩展方法(非 ABC 接口)
|
{remote_cmd_manager-2.6.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/credential_provider.py
RENAMED
|
@@ -32,6 +32,23 @@ from remote_cmd.utils.crypto import CredentialEncryption
|
|
|
32
32
|
logger = logging.getLogger(__name__)
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def _keyring_backend_errors() -> tuple[type[BaseException], ...]:
|
|
36
|
+
"""keyring 后端不可用/存取失败的异常集合(v2.7 P2.2)。
|
|
37
|
+
|
|
38
|
+
惰性导入(keyring 为可选依赖),仅覆盖**后端类**失败:
|
|
39
|
+
|
|
40
|
+
- ``keyring.errors.KeyringError`` 及其子类(NoKeyringError / KeyringLocked 等)
|
|
41
|
+
- ``OSError``:后端 I/O 失败(文件后端 / D-Bus 等)
|
|
42
|
+
- ``RuntimeError``:部分后端无可用实现时抛出的运行态错误
|
|
43
|
+
|
|
44
|
+
编程错误(TypeError / AttributeError 等)不再被吞掉,会向上传播,
|
|
45
|
+
避免把代码缺陷伪装成"keyring 不可用"。
|
|
46
|
+
"""
|
|
47
|
+
from keyring import errors as keyring_errors
|
|
48
|
+
|
|
49
|
+
return (keyring_errors.KeyringError, OSError, RuntimeError)
|
|
50
|
+
|
|
51
|
+
|
|
35
52
|
class CredentialProvider(ABC):
|
|
36
53
|
"""凭据提供者抽象基类"""
|
|
37
54
|
|
|
@@ -177,7 +194,7 @@ class KeyringCredentialProvider(CredentialProvider):
|
|
|
177
194
|
except ImportError:
|
|
178
195
|
logger.debug("keyring not installed, skipping KeyringCredentialProvider")
|
|
179
196
|
return None
|
|
180
|
-
except
|
|
197
|
+
except _keyring_backend_errors() as e:
|
|
181
198
|
logger.debug(f"keyring access failed: {e}")
|
|
182
199
|
return None
|
|
183
200
|
|
|
@@ -197,7 +214,10 @@ class KeyringCredentialProvider(CredentialProvider):
|
|
|
197
214
|
|
|
198
215
|
keyring.set_password(self._service_name, host.name, password)
|
|
199
216
|
return True
|
|
200
|
-
except
|
|
217
|
+
except ImportError:
|
|
218
|
+
logger.debug("keyring not installed, cannot store password")
|
|
219
|
+
return False
|
|
220
|
+
except _keyring_backend_errors() as e:
|
|
201
221
|
logger.debug(f"keyring store failed: {e}")
|
|
202
222
|
return False
|
|
203
223
|
|
|
@@ -216,6 +236,9 @@ class KeyringCredentialProvider(CredentialProvider):
|
|
|
216
236
|
|
|
217
237
|
keyring.delete_password(self._service_name, host.name)
|
|
218
238
|
return True
|
|
219
|
-
except
|
|
239
|
+
except ImportError:
|
|
240
|
+
logger.debug("keyring not installed, cannot delete password")
|
|
241
|
+
return False
|
|
242
|
+
except _keyring_backend_errors() as e:
|
|
220
243
|
logger.debug(f"keyring delete failed: {e}")
|
|
221
244
|
return False
|
|
@@ -37,7 +37,7 @@ from remote_cmd.service.credential_provider import (
|
|
|
37
37
|
EnvCredentialProvider,
|
|
38
38
|
)
|
|
39
39
|
from remote_cmd.service.ssh_service import SSHService
|
|
40
|
-
from remote_cmd.utils.crypto import CredentialEncryption
|
|
40
|
+
from remote_cmd.utils.crypto import CredentialEncryption, CredentialEncryptionError
|
|
41
41
|
|
|
42
42
|
logger = logging.getLogger(__name__)
|
|
43
43
|
|
|
@@ -245,7 +245,7 @@ class HostService:
|
|
|
245
245
|
try:
|
|
246
246
|
decrypted = self._encryption.decrypt(host.password)
|
|
247
247
|
return self._build_resolved_host(host, decrypted, host.key_filename)
|
|
248
|
-
except
|
|
248
|
+
except CredentialEncryptionError as e:
|
|
249
249
|
# 解密失败不应阻塞整批主机返回:保留加密 token,
|
|
250
250
|
# 让 SSH 层在真正连接时报告认证失败
|
|
251
251
|
logger.warning(f"failed to decrypt password for {host.name}: {e}")
|
|
@@ -281,7 +281,7 @@ class HostService:
|
|
|
281
281
|
# 凭据链未命中,回退到本地加密器解密
|
|
282
282
|
try:
|
|
283
283
|
resolved_password = self._encryption.decrypt(host.password)
|
|
284
|
-
except
|
|
284
|
+
except CredentialEncryptionError as e:
|
|
285
285
|
logger.warning(f"failed to decrypt password for {host.name}: {e}")
|
|
286
286
|
# 保留加密 token,留给 SSH 层报认证失败
|
|
287
287
|
|
|
@@ -91,7 +91,7 @@ class CredentialEncryption:
|
|
|
91
91
|
try:
|
|
92
92
|
token = self._cipher.encrypt(plaintext.encode("utf-8"))
|
|
93
93
|
return self._PREFIX + token.decode("utf-8")
|
|
94
|
-
except
|
|
94
|
+
except (TypeError, ValueError) as e:
|
|
95
95
|
raise CredentialEncryptionError(f"encryption failed: {e}") from e
|
|
96
96
|
|
|
97
97
|
def decrypt(self, ciphertext: str) -> str:
|
|
@@ -110,10 +110,15 @@ class CredentialEncryption:
|
|
|
110
110
|
if not ciphertext.startswith(self._PREFIX):
|
|
111
111
|
raise CredentialEncryptionError("invalid ciphertext format")
|
|
112
112
|
|
|
113
|
+
from cryptography.fernet import InvalidToken
|
|
114
|
+
|
|
113
115
|
try:
|
|
114
116
|
token = ciphertext[len(self._PREFIX) :].encode("utf-8")
|
|
115
117
|
return self._cipher.decrypt(token).decode("utf-8")
|
|
116
|
-
|
|
118
|
+
# InvalidToken:签名/内容损坏;ValueError(含 binascii.Error):
|
|
119
|
+
# base64/格式非法;TypeError:token 类型错误。其余异常(如密钥加载
|
|
120
|
+
# 缺陷或 cryptography 内部错误)向上传播,不再伪装为"解密失败"。
|
|
121
|
+
except (InvalidToken, ValueError, TypeError) as e:
|
|
117
122
|
raise CredentialEncryptionError(f"decryption failed: {e}") from e
|
|
118
123
|
|
|
119
124
|
def is_encrypted(self, value: str) -> bool:
|