remote-cmd-manager 1.2.3__tar.gz → 2.0.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-1.2.3/remote_cmd_manager.egg-info → remote_cmd_manager-2.0.0}/PKG-INFO +1 -1
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/pyproject.toml +4 -1
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/__init__.py +3 -1
- remote_cmd_manager-2.0.0/remote_cmd/_version.py +12 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/cli/main.py +22 -18
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/async_connection_pool.py +33 -24
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/async_ssh_client.py +20 -15
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/host.py +4 -5
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/ssh_client.py +41 -23
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/sync_connection_pool.py +33 -24
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/repository/json_host_repository.py +11 -12
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/repository/sqlite_host_repository.py +12 -10
- remote_cmd_manager-2.0.0/remote_cmd/service/_host_runner.py +115 -0
- remote_cmd_manager-2.0.0/remote_cmd/service/_pool_policy.py +126 -0
- remote_cmd_manager-2.0.0/remote_cmd/service/_types.py +93 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/async_batch_executor.py +25 -42
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/batch_executor.py +39 -140
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/credential_provider.py +4 -4
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/host_service.py +44 -39
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/ssh_service.py +1 -1
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/task_runner.py +1 -1
- remote_cmd_manager-2.0.0/remote_cmd/utils/credential_guard.py +126 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/utils/crypto.py +6 -5
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0/remote_cmd_manager.egg-info}/PKG-INFO +1 -1
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/SOURCES.txt +6 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_async_batch_executor.py +50 -5
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_async_ssh_client.py +2 -2
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_batch_executor.py +41 -0
- remote_cmd_manager-2.0.0/tests/test_pool_policy.py +73 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_ssh_client.py +2 -2
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_sync_connection_pool.py +22 -5
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/LICENSE +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/MANIFEST.in +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/README.md +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/config.example.yaml +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/examples/basic_usage.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/examples/deploy_script.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/examples/nginx_batch_update.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/examples/system_health_check.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/cli/__init__.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/__init__.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/host_manager.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/repository/__init__.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/repository/host_repository.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/__init__.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/service/storage_factory.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/utils/__init__.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/utils/config.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/utils/exceptions.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/utils/logging_utils.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/dependency_links.txt +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/entry_points.txt +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/requires.txt +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/top_level.txt +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/requirements.txt +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/setup.cfg +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/__init__.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/conftest.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/integration/conftest.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/integration/test_ssh_connection.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/performance/__init__.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/performance/conftest.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/performance/test_benchmarks.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_cli.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_config.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_credential_provider.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_crypto.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_host.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_host_manager.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_host_service.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_keyring_provider.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_logging_utils.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_repository.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_sqlite_repository.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_ssh_service.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_storage_factory.py +0 -0
- {remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/tests/test_task_runner.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "remote_cmd_manager"
|
|
7
|
-
|
|
7
|
+
dynamic = ["version"]
|
|
8
8
|
description = "SSH 远程服务器管理工具 / Python SSH remote server management tool"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -63,6 +63,9 @@ Download = "https://pypi.org/project/remote_cmd_manager/"
|
|
|
63
63
|
[project.scripts]
|
|
64
64
|
remote-cmd = "remote_cmd.cli.main:main"
|
|
65
65
|
|
|
66
|
+
[tool.setuptools.dynamic]
|
|
67
|
+
version = { attr = "remote_cmd._version.__version__" }
|
|
68
|
+
|
|
66
69
|
[tool.setuptools.packages.find]
|
|
67
70
|
include = ["remote_cmd*"]
|
|
68
71
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
版本号单一真相源(无任何 import,纯常量)
|
|
3
|
+
|
|
4
|
+
供 ``remote_cmd/__init__.py`` 与 ``pyproject.toml`` 的 setuptools 动态版本读取。
|
|
5
|
+
|
|
6
|
+
设计约定:
|
|
7
|
+
- 本模块不 import 任何东西,无副作用——setuptools 通过 ``attr`` 解析版本时
|
|
8
|
+
不会触发 ``remote_cmd`` 包级 import(避免干净构建环境缺依赖时 ImportError)。
|
|
9
|
+
- ``__version__`` 由 ``remote_cmd/__init__.py`` 导入并继续作为公共 API 导出。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "2.0.0"
|
|
@@ -18,6 +18,7 @@ Main commands:
|
|
|
18
18
|
|
|
19
19
|
import getpass
|
|
20
20
|
import os
|
|
21
|
+
from pathlib import Path
|
|
21
22
|
from typing import Any, Optional
|
|
22
23
|
|
|
23
24
|
import click
|
|
@@ -77,7 +78,12 @@ def _build_service(config_file: str, storage_backend: Optional[str] = None) -> H
|
|
|
77
78
|
)
|
|
78
79
|
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output mode")
|
|
79
80
|
@click.pass_context
|
|
80
|
-
def cli(
|
|
81
|
+
def cli(
|
|
82
|
+
ctx: click.Context,
|
|
83
|
+
config: Optional[str],
|
|
84
|
+
hosts_file_override: Optional[str],
|
|
85
|
+
verbose: bool,
|
|
86
|
+
) -> None:
|
|
81
87
|
"""
|
|
82
88
|
Remote CMD - SSH remote server management tool
|
|
83
89
|
|
|
@@ -115,7 +121,7 @@ def cli(ctx, config: Optional[str], hosts_file_override: Optional[str], verbose:
|
|
|
115
121
|
|
|
116
122
|
|
|
117
123
|
@cli.group()
|
|
118
|
-
def host():
|
|
124
|
+
def host() -> None:
|
|
119
125
|
"""
|
|
120
126
|
Host management command group
|
|
121
127
|
|
|
@@ -141,7 +147,7 @@ def host():
|
|
|
141
147
|
@click.option("--description", "-d", default="", help="Host description")
|
|
142
148
|
@click.pass_context
|
|
143
149
|
def host_add(
|
|
144
|
-
ctx,
|
|
150
|
+
ctx: click.Context,
|
|
145
151
|
name: str,
|
|
146
152
|
hostname: str,
|
|
147
153
|
username: str,
|
|
@@ -149,7 +155,7 @@ def host_add(
|
|
|
149
155
|
key: Optional[str],
|
|
150
156
|
tag: tuple,
|
|
151
157
|
description: str,
|
|
152
|
-
):
|
|
158
|
+
) -> None:
|
|
153
159
|
"""
|
|
154
160
|
Add a new host
|
|
155
161
|
|
|
@@ -207,7 +213,7 @@ def host_add(
|
|
|
207
213
|
@host.command("list")
|
|
208
214
|
@click.option("--tag", "-t", help="Filter hosts by tag")
|
|
209
215
|
@click.pass_context
|
|
210
|
-
def host_list(ctx, tag: Optional[str]):
|
|
216
|
+
def host_list(ctx: click.Context, tag: Optional[str]) -> None:
|
|
211
217
|
"""List all hosts"""
|
|
212
218
|
service: HostService = ctx.obj["service"]
|
|
213
219
|
hosts = service.list_hosts(tag=tag)
|
|
@@ -233,7 +239,7 @@ def host_list(ctx, tag: Optional[str]):
|
|
|
233
239
|
@click.argument("name", required=True)
|
|
234
240
|
@click.confirmation_option(prompt="Are you sure you want to remove this host?")
|
|
235
241
|
@click.pass_context
|
|
236
|
-
def host_remove(ctx, name: str):
|
|
242
|
+
def host_remove(ctx: click.Context, name: str) -> None:
|
|
237
243
|
"""Remove a host"""
|
|
238
244
|
service: HostService = ctx.obj["service"]
|
|
239
245
|
|
|
@@ -250,7 +256,7 @@ def host_remove(ctx, name: str):
|
|
|
250
256
|
@host.command("show")
|
|
251
257
|
@click.argument("name", required=True)
|
|
252
258
|
@click.pass_context
|
|
253
|
-
def host_show(ctx, name: str):
|
|
259
|
+
def host_show(ctx: click.Context, name: str) -> None:
|
|
254
260
|
"""Show host details"""
|
|
255
261
|
service: HostService = ctx.obj["service"]
|
|
256
262
|
|
|
@@ -272,8 +278,6 @@ def host_show(ctx, name: str):
|
|
|
272
278
|
click.echo(f" Auth: {auth_type}")
|
|
273
279
|
if host.key_filename:
|
|
274
280
|
# Sanitize key path: show only the filename, not full path
|
|
275
|
-
from pathlib import Path
|
|
276
|
-
|
|
277
281
|
key_name = Path(host.key_filename).name
|
|
278
282
|
click.echo(f" Key file: {key_name}")
|
|
279
283
|
tags_str = ", ".join(host.tags) if host.tags else "-"
|
|
@@ -290,7 +294,7 @@ def host_show(ctx, name: str):
|
|
|
290
294
|
@host.command("test")
|
|
291
295
|
@click.argument("name", required=True)
|
|
292
296
|
@click.pass_context
|
|
293
|
-
def host_test(ctx, name: str):
|
|
297
|
+
def host_test(ctx: click.Context, name: str) -> None:
|
|
294
298
|
"""Test host connection"""
|
|
295
299
|
service: HostService = ctx.obj["service"]
|
|
296
300
|
|
|
@@ -307,7 +311,7 @@ def host_test(ctx, name: str):
|
|
|
307
311
|
@click.argument("host_name", required=True)
|
|
308
312
|
@click.argument("command", required=True)
|
|
309
313
|
@click.pass_context
|
|
310
|
-
def run(ctx, host_name: str, command: str):
|
|
314
|
+
def run(ctx: click.Context, host_name: str, command: str) -> None:
|
|
311
315
|
"""
|
|
312
316
|
Execute a command on a remote host
|
|
313
317
|
|
|
@@ -340,7 +344,7 @@ def run(ctx, host_name: str, command: str):
|
|
|
340
344
|
@click.argument("local_path", required=True)
|
|
341
345
|
@click.argument("remote_path", required=True)
|
|
342
346
|
@click.pass_context
|
|
343
|
-
def upload(ctx, host_name: str, local_path: str, remote_path: str):
|
|
347
|
+
def upload(ctx: click.Context, host_name: str, local_path: str, remote_path: str) -> None:
|
|
344
348
|
"""
|
|
345
349
|
Upload a file to a remote host
|
|
346
350
|
|
|
@@ -366,7 +370,7 @@ def upload(ctx, host_name: str, local_path: str, remote_path: str):
|
|
|
366
370
|
@click.argument("local_path", required=True)
|
|
367
371
|
@click.argument("remote_path", required=True)
|
|
368
372
|
@click.pass_context
|
|
369
|
-
def download(ctx, host_name: str, local_path: str, remote_path: str):
|
|
373
|
+
def download(ctx: click.Context, host_name: str, local_path: str, remote_path: str) -> None:
|
|
370
374
|
"""
|
|
371
375
|
Download a file from a remote host
|
|
372
376
|
|
|
@@ -398,8 +402,8 @@ def download(ctx, host_name: str, local_path: str, remote_path: str):
|
|
|
398
402
|
@click.option("--show-failures", is_flag=True, help="Show only failed hosts")
|
|
399
403
|
@click.pass_context
|
|
400
404
|
def batch_run(
|
|
401
|
-
ctx,
|
|
402
|
-
host_names: tuple,
|
|
405
|
+
ctx: click.Context,
|
|
406
|
+
host_names: tuple[str, ...],
|
|
403
407
|
command: str,
|
|
404
408
|
concurrency: int,
|
|
405
409
|
timeout: int,
|
|
@@ -407,7 +411,7 @@ def batch_run(
|
|
|
407
411
|
retry_delay: float,
|
|
408
412
|
use_async: bool,
|
|
409
413
|
show_failures: bool,
|
|
410
|
-
):
|
|
414
|
+
) -> None:
|
|
411
415
|
"""
|
|
412
416
|
Execute a command on multiple hosts in batch
|
|
413
417
|
|
|
@@ -443,7 +447,7 @@ def batch_run(
|
|
|
443
447
|
show_percent=True,
|
|
444
448
|
) as bar:
|
|
445
449
|
|
|
446
|
-
def progress(_completed, _total, _host_name):
|
|
450
|
+
def progress(_completed: int, _total: int, _host_name: str) -> None:
|
|
447
451
|
bar.update(1)
|
|
448
452
|
|
|
449
453
|
result = executor.execute(
|
|
@@ -488,7 +492,7 @@ def batch_run(
|
|
|
488
492
|
ctx.exit(1)
|
|
489
493
|
|
|
490
494
|
|
|
491
|
-
def main():
|
|
495
|
+
def main() -> None:
|
|
492
496
|
"""CLI entry point"""
|
|
493
497
|
cli()
|
|
494
498
|
|
{remote_cmd_manager-1.2.3 → remote_cmd_manager-2.0.0}/remote_cmd/core/async_connection_pool.py
RENAMED
|
@@ -20,6 +20,12 @@ from typing import Any, Optional
|
|
|
20
20
|
|
|
21
21
|
from remote_cmd.core.async_ssh_client import AsyncSSHClient
|
|
22
22
|
from remote_cmd.core.ssh_client import ConnectionConfig
|
|
23
|
+
from remote_cmd.service._pool_policy import (
|
|
24
|
+
ConnectionMeta,
|
|
25
|
+
idle_expired,
|
|
26
|
+
lifetime_expired,
|
|
27
|
+
should_close,
|
|
28
|
+
)
|
|
23
29
|
|
|
24
30
|
logger = logging.getLogger(__name__)
|
|
25
31
|
|
|
@@ -55,6 +61,9 @@ class AsyncConnectionPool:
|
|
|
55
61
|
self._semaphore = asyncio.Semaphore(max_connections)
|
|
56
62
|
self._lock = asyncio.Lock()
|
|
57
63
|
|
|
64
|
+
# 生命周期状态:close_all() 后置 True,禁止再借用/归还
|
|
65
|
+
self._closed = False
|
|
66
|
+
|
|
58
67
|
# 指标
|
|
59
68
|
self._total_created = 0
|
|
60
69
|
self._total_reconnects = 0
|
|
@@ -65,7 +74,7 @@ class AsyncConnectionPool:
|
|
|
65
74
|
self._monitor_task: Optional[asyncio.Task[None]] = None
|
|
66
75
|
|
|
67
76
|
# 连接元数据(副表,避免侵入 AsyncSSHClient 私有属性)
|
|
68
|
-
self._meta: dict[int,
|
|
77
|
+
self._meta: dict[int, ConnectionMeta] = {}
|
|
69
78
|
|
|
70
79
|
# ------------------------------------------------------------------
|
|
71
80
|
# 指标
|
|
@@ -98,7 +107,10 @@ class AsyncConnectionPool:
|
|
|
98
107
|
|
|
99
108
|
Raises:
|
|
100
109
|
SSHConnectionError: 创建连接失败
|
|
110
|
+
RuntimeError: 连接池已关闭(close_all 之后)
|
|
101
111
|
"""
|
|
112
|
+
if self._closed:
|
|
113
|
+
raise RuntimeError("connection pool is closed")
|
|
102
114
|
await self._semaphore.acquire()
|
|
103
115
|
try:
|
|
104
116
|
# 优先复用空闲连接
|
|
@@ -119,9 +131,15 @@ class AsyncConnectionPool:
|
|
|
119
131
|
"""归还连接到池中(如已断开/超时则关闭)。"""
|
|
120
132
|
if conn is None:
|
|
121
133
|
return
|
|
134
|
+
# 池已关闭:不把连接放回空闲队列(避免游离连接),直接关闭并释放槽位
|
|
135
|
+
if self._closed:
|
|
136
|
+
await self._close_connection(conn)
|
|
137
|
+
self._semaphore.release()
|
|
138
|
+
self._total_released += 1
|
|
139
|
+
return
|
|
122
140
|
meta = self._meta.get(id(conn))
|
|
123
141
|
if meta is not None:
|
|
124
|
-
meta
|
|
142
|
+
meta.last_used = time.time()
|
|
125
143
|
|
|
126
144
|
if not conn.is_connected():
|
|
127
145
|
await self._close_connection(conn)
|
|
@@ -129,10 +147,7 @@ class AsyncConnectionPool:
|
|
|
129
147
|
return
|
|
130
148
|
|
|
131
149
|
# 生命周期 / 空闲超时则关闭
|
|
132
|
-
if meta and (
|
|
133
|
-
time.time() - meta["created_at"] > self._max_lifetime
|
|
134
|
-
or time.time() - meta["last_used"] > self._idle_timeout
|
|
135
|
-
):
|
|
150
|
+
if meta and should_close(meta, self._max_lifetime, self._idle_timeout, True):
|
|
136
151
|
await self._close_connection(conn)
|
|
137
152
|
self._semaphore.release()
|
|
138
153
|
return
|
|
@@ -162,18 +177,18 @@ class AsyncConnectionPool:
|
|
|
162
177
|
raise
|
|
163
178
|
self._connections.append(client)
|
|
164
179
|
now = time.time()
|
|
165
|
-
self._meta[id(client)] =
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
180
|
+
self._meta[id(client)] = ConnectionMeta(
|
|
181
|
+
created_at=now,
|
|
182
|
+
last_used=now,
|
|
183
|
+
conn_id=uuid.uuid4().hex,
|
|
184
|
+
)
|
|
170
185
|
self._total_created += 1
|
|
171
186
|
return client
|
|
172
187
|
|
|
173
188
|
def _touch(self, conn: AsyncSSHClient) -> None:
|
|
174
189
|
meta = self._meta.get(id(conn))
|
|
175
190
|
if meta is not None:
|
|
176
|
-
meta
|
|
191
|
+
meta.last_used = time.time()
|
|
177
192
|
|
|
178
193
|
async def _check_connection(self, conn: AsyncSSHClient) -> bool:
|
|
179
194
|
if not conn.is_connected():
|
|
@@ -181,14 +196,12 @@ class AsyncConnectionPool:
|
|
|
181
196
|
meta = self._meta.get(id(conn))
|
|
182
197
|
if meta is None:
|
|
183
198
|
return True
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
logger.debug("connection %s exceeded max lifetime", meta.get("conn_id", "?")[:8])
|
|
199
|
+
if lifetime_expired(meta.created_at, self._max_lifetime):
|
|
200
|
+
logger.debug("connection %s exceeded max lifetime", meta.conn_id[:8])
|
|
187
201
|
return False
|
|
188
202
|
# 连接刚使用过(空闲未超时)则信任其状态,避免频繁探活开销
|
|
189
203
|
# (与 SyncConnectionPool._check_connection 保持一致)
|
|
190
|
-
|
|
191
|
-
if idle < self._idle_timeout:
|
|
204
|
+
if not idle_expired(meta.last_used, self._idle_timeout):
|
|
192
205
|
return True
|
|
193
206
|
# 空闲较久才触发轻量探活:发出一个无害命令
|
|
194
207
|
try:
|
|
@@ -239,15 +252,10 @@ class AsyncConnectionPool:
|
|
|
239
252
|
keep: list[AsyncSSHClient] = []
|
|
240
253
|
for conn in snapshot:
|
|
241
254
|
meta = self._meta.get(id(conn))
|
|
242
|
-
if meta
|
|
255
|
+
if should_close(meta, self._max_lifetime, self._idle_timeout, conn.is_connected(), now):
|
|
243
256
|
await self._close_connection(conn)
|
|
244
257
|
continue
|
|
245
|
-
|
|
246
|
-
idle = now - meta["last_used"]
|
|
247
|
-
if age < self._max_lifetime and idle < self._idle_timeout and conn.is_connected():
|
|
248
|
-
keep.append(conn)
|
|
249
|
-
else:
|
|
250
|
-
await self._close_connection(conn)
|
|
258
|
+
keep.append(conn)
|
|
251
259
|
# 将存活连接放回同一队列对象
|
|
252
260
|
async with self._lock:
|
|
253
261
|
for conn in keep:
|
|
@@ -275,6 +283,7 @@ class AsyncConnectionPool:
|
|
|
275
283
|
|
|
276
284
|
async def close_all(self) -> None:
|
|
277
285
|
"""关闭池中所有连接并停止监控。"""
|
|
286
|
+
self._closed = True
|
|
278
287
|
self.stop_monitor()
|
|
279
288
|
for conn in list(self._connections):
|
|
280
289
|
await self._close_connection(conn)
|
|
@@ -22,7 +22,7 @@ from typing import Any, Optional
|
|
|
22
22
|
|
|
23
23
|
import asyncssh
|
|
24
24
|
|
|
25
|
-
from remote_cmd.core.ssh_client import CommandResult, ConnectionConfig
|
|
25
|
+
from remote_cmd.core.ssh_client import CommandResult, ConnectionConfig, RemoteFileEntry
|
|
26
26
|
from remote_cmd.utils.exceptions import (
|
|
27
27
|
SSHCommandError,
|
|
28
28
|
SSHConnectionError,
|
|
@@ -195,7 +195,8 @@ class AsyncSSHClient:
|
|
|
195
195
|
+ "; "
|
|
196
196
|
)
|
|
197
197
|
full_command = f"{env_str}cd ~ && {command}"
|
|
198
|
-
|
|
198
|
+
# 安全:不记录命令全文(可能含敏感参数),仅记录执行事件
|
|
199
|
+
logger.debug("executing remote command")
|
|
199
200
|
try:
|
|
200
201
|
result = await conn.run(
|
|
201
202
|
full_command,
|
|
@@ -204,7 +205,7 @@ class AsyncSSHClient:
|
|
|
204
205
|
env={k: str(v) for k, v in (environment or {}).items()},
|
|
205
206
|
)
|
|
206
207
|
except (OSError, asyncssh.Error) as e:
|
|
207
|
-
raise SSHCommandError(f"command execution failed
|
|
208
|
+
raise SSHCommandError(f"command execution failed: {e}") from e
|
|
208
209
|
|
|
209
210
|
stdout_data = (
|
|
210
211
|
result.stdout
|
|
@@ -242,14 +243,16 @@ class AsyncSSHClient:
|
|
|
242
243
|
timeout=timeout,
|
|
243
244
|
)
|
|
244
245
|
except (OSError, asyncssh.Error) as e:
|
|
245
|
-
raise SSHCommandError(f"sudo command execution failed
|
|
246
|
+
raise SSHCommandError(f"sudo command execution failed: {e}") from e
|
|
246
247
|
|
|
247
248
|
try:
|
|
248
249
|
proc.stdin.write(password + "\n")
|
|
249
250
|
proc.stdin.write_eof()
|
|
250
|
-
|
|
251
|
+
# 与 execute 的 conn.run(timeout=...) 语义对齐:timeout 覆盖整个命令执行
|
|
252
|
+
# wall-clock,避免挂起的 sudo(如等待密码)无限等待
|
|
253
|
+
result = await proc.wait(timeout=timeout)
|
|
251
254
|
except (OSError, asyncssh.Error) as e:
|
|
252
|
-
raise SSHCommandError(f"sudo command execution failed
|
|
255
|
+
raise SSHCommandError(f"sudo command execution failed: {e}") from e
|
|
253
256
|
|
|
254
257
|
stdout_data = (
|
|
255
258
|
result.stdout
|
|
@@ -305,7 +308,7 @@ class AsyncSSHClient:
|
|
|
305
308
|
raise SSHFileTransferError(f"file download failed: {e}") from e
|
|
306
309
|
logger.info("file download finished")
|
|
307
310
|
|
|
308
|
-
async def list_remote_directory(self, remote_path: str = ".") -> list[
|
|
311
|
+
async def list_remote_directory(self, remote_path: str = ".") -> list[RemoteFileEntry]:
|
|
309
312
|
"""异步列出远程目录内容(结构与同步 SSHClient 一致)。"""
|
|
310
313
|
sftp = await self._get_sftp()
|
|
311
314
|
try:
|
|
@@ -313,18 +316,20 @@ class AsyncSSHClient:
|
|
|
313
316
|
except (OSError, asyncssh.Error) as e:
|
|
314
317
|
raise SSHFileTransferError(f"failed to list remote directory: {e}") from e
|
|
315
318
|
|
|
316
|
-
entries: list[
|
|
319
|
+
entries: list[RemoteFileEntry] = []
|
|
317
320
|
for entry in names:
|
|
318
321
|
attrs = entry.attrs
|
|
319
322
|
mode = attrs.permissions if hasattr(attrs, "permissions") else None
|
|
323
|
+
raw_size = attrs.size if hasattr(attrs, "size") else None
|
|
324
|
+
raw_mtime = attrs.mtime if hasattr(attrs, "mtime") else 0
|
|
320
325
|
entries.append(
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
326
|
+
RemoteFileEntry(
|
|
327
|
+
name=str(entry.filename),
|
|
328
|
+
size=raw_size if raw_size is not None else 0,
|
|
329
|
+
mode=oct(int(mode))[-3:] if mode else "000",
|
|
330
|
+
mtime=raw_mtime,
|
|
331
|
+
is_dir=bool(mode & stat.S_IFDIR) if mode else False,
|
|
332
|
+
)
|
|
328
333
|
)
|
|
329
334
|
return entries
|
|
330
335
|
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
- 与连接配置的转换
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
-
from dataclasses import asdict, dataclass
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
13
14
|
from typing import Any, Optional
|
|
14
15
|
|
|
15
16
|
from remote_cmd.core.ssh_client import ConnectionConfig
|
|
@@ -49,11 +50,11 @@ class Host:
|
|
|
49
50
|
port: int = 22
|
|
50
51
|
password: Optional[str] = None
|
|
51
52
|
key_filename: Optional[str] = None
|
|
52
|
-
tags:
|
|
53
|
+
tags: list[str] = field(default_factory=list)
|
|
53
54
|
description: str = ""
|
|
54
55
|
|
|
55
56
|
def __post_init__(self):
|
|
56
|
-
"""
|
|
57
|
+
"""初始化后处理:归一化外部传入的 None 标签(兼容旧数据)"""
|
|
57
58
|
if self.tags is None:
|
|
58
59
|
self.tags = []
|
|
59
60
|
|
|
@@ -124,8 +125,6 @@ class Host:
|
|
|
124
125
|
)
|
|
125
126
|
# 脱敏密钥路径:仅显示文件名
|
|
126
127
|
if data.get("key_filename"):
|
|
127
|
-
from pathlib import Path
|
|
128
|
-
|
|
129
128
|
data["key_filename"] = Path(data["key_filename"]).name
|
|
130
129
|
return data
|
|
131
130
|
|
|
@@ -137,6 +137,28 @@ class CommandResult:
|
|
|
137
137
|
return f"{status} [{self.exit_code}] {self.command}"
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
@dataclass
|
|
141
|
+
class RemoteFileEntry:
|
|
142
|
+
"""
|
|
143
|
+
远程文件/目录条目信息
|
|
144
|
+
|
|
145
|
+
描述远程目录中的单个条目(文件或目录),替代弱类型的字典返回。
|
|
146
|
+
|
|
147
|
+
Attributes:
|
|
148
|
+
name: 文件/目录名
|
|
149
|
+
size: 文件大小(字节)
|
|
150
|
+
mode: 权限模式(八进制字符串)
|
|
151
|
+
mtime: 修改时间戳
|
|
152
|
+
is_dir: 是否为目录
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
name: str
|
|
156
|
+
size: int
|
|
157
|
+
mode: str
|
|
158
|
+
mtime: Any
|
|
159
|
+
is_dir: bool
|
|
160
|
+
|
|
161
|
+
|
|
140
162
|
# ============================================================================
|
|
141
163
|
# SSH 客户端类
|
|
142
164
|
# ============================================================================
|
|
@@ -166,7 +188,7 @@ class SSHClient:
|
|
|
166
188
|
... print(result.stdout)
|
|
167
189
|
"""
|
|
168
190
|
|
|
169
|
-
def __init__(self, config: ConnectionConfig):
|
|
191
|
+
def __init__(self, config: ConnectionConfig) -> None:
|
|
170
192
|
"""
|
|
171
193
|
初始化 SSH 客户端
|
|
172
194
|
|
|
@@ -375,7 +397,8 @@ class SSHClient:
|
|
|
375
397
|
raise SSHConnectionError("not connected, call connect() first")
|
|
376
398
|
|
|
377
399
|
try:
|
|
378
|
-
|
|
400
|
+
# 安全:不记录命令全文(可能含敏感参数),仅记录执行事件
|
|
401
|
+
logger.debug("executing remote command")
|
|
379
402
|
|
|
380
403
|
# 构建环境变量设置命令
|
|
381
404
|
# 安全:对 value 做 shlex.quote 转义,防止包含 shell 元字符
|
|
@@ -408,7 +431,7 @@ class SSHClient:
|
|
|
408
431
|
return result
|
|
409
432
|
|
|
410
433
|
except (paramiko.SSHException, OSError) as e:
|
|
411
|
-
raise SSHCommandError(f"command execution failed
|
|
434
|
+
raise SSHCommandError(f"command execution failed: {e}") from e
|
|
412
435
|
|
|
413
436
|
def execute_sudo(
|
|
414
437
|
self,
|
|
@@ -464,7 +487,7 @@ class SSHClient:
|
|
|
464
487
|
exit_code=exit_code,
|
|
465
488
|
)
|
|
466
489
|
except (paramiko.SSHException, OSError) as e:
|
|
467
|
-
raise SSHCommandError(f"sudo command execution failed
|
|
490
|
+
raise SSHCommandError(f"sudo command execution failed: {e}") from e
|
|
468
491
|
|
|
469
492
|
# ========================================================================
|
|
470
493
|
# 文件传输方法
|
|
@@ -532,7 +555,7 @@ class SSHClient:
|
|
|
532
555
|
except (paramiko.SSHException, OSError) as e:
|
|
533
556
|
raise SSHFileTransferError(f"file download failed: {e}") from e
|
|
534
557
|
|
|
535
|
-
def list_remote_directory(self, remote_path: str = ".") -> list[
|
|
558
|
+
def list_remote_directory(self, remote_path: str = ".") -> list[RemoteFileEntry]:
|
|
536
559
|
"""
|
|
537
560
|
列出远程目录内容
|
|
538
561
|
|
|
@@ -540,12 +563,7 @@ class SSHClient:
|
|
|
540
563
|
remote_path: 远程目录路径,默认为当前目录
|
|
541
564
|
|
|
542
565
|
Returns:
|
|
543
|
-
List[
|
|
544
|
-
- name: 文件/目录名
|
|
545
|
-
- size: 文件大小(字节)
|
|
546
|
-
- mode: 权限模式(八进制字符串)
|
|
547
|
-
- mtime: 修改时间戳
|
|
548
|
-
- is_dir: 是否为目录
|
|
566
|
+
List[RemoteFileEntry]: 目录项信息列表
|
|
549
567
|
|
|
550
568
|
Raises:
|
|
551
569
|
SSHFileTransferError: 列出目录失败时抛出
|
|
@@ -554,22 +572,22 @@ class SSHClient:
|
|
|
554
572
|
Example:
|
|
555
573
|
>>> entries = client.list_remote_directory("/home/user")
|
|
556
574
|
>>> for entry in entries:
|
|
557
|
-
... print(f"{entry
|
|
575
|
+
... print(f"{entry.name}: {entry.size} bytes")
|
|
558
576
|
"""
|
|
559
577
|
sftp = self._get_sftp()
|
|
560
578
|
|
|
561
579
|
try:
|
|
562
|
-
entries = []
|
|
580
|
+
entries: list[RemoteFileEntry] = []
|
|
563
581
|
for entry in sftp.listdir_attr(remote_path):
|
|
564
582
|
mode = entry.st_mode if entry.st_mode is not None else 0
|
|
565
583
|
entries.append(
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
584
|
+
RemoteFileEntry(
|
|
585
|
+
name=entry.filename,
|
|
586
|
+
size=entry.st_size,
|
|
587
|
+
mode=oct(mode)[-3:] if mode else "000",
|
|
588
|
+
mtime=entry.st_mtime,
|
|
589
|
+
is_dir=bool(mode & stat.S_IFDIR) if mode else False,
|
|
590
|
+
)
|
|
573
591
|
)
|
|
574
592
|
return entries
|
|
575
593
|
except (paramiko.SSHException, OSError) as e:
|
|
@@ -579,7 +597,7 @@ class SSHClient:
|
|
|
579
597
|
"""创建远程目录(支持递归创建)"""
|
|
580
598
|
sftp = self._get_sftp()
|
|
581
599
|
|
|
582
|
-
def _makedirs(sftp_client, remote_path):
|
|
600
|
+
def _makedirs(sftp_client: paramiko.SFTPClient, remote_path: str) -> None:
|
|
583
601
|
if remote_path == "/":
|
|
584
602
|
return
|
|
585
603
|
try:
|
|
@@ -607,9 +625,9 @@ class SSHClient:
|
|
|
607
625
|
"""删除远程目录"""
|
|
608
626
|
sftp = self._get_sftp()
|
|
609
627
|
|
|
610
|
-
def _rm_recursive(sftp_client, remote_path):
|
|
628
|
+
def _rm_recursive(sftp_client: paramiko.SFTPClient, remote_path: str) -> None:
|
|
611
629
|
"""递归删除目录内容,先收集后删除以避免不一致状态"""
|
|
612
|
-
entries = []
|
|
630
|
+
entries: list[tuple[str, bool]] = []
|
|
613
631
|
try:
|
|
614
632
|
for entry in sftp_client.listdir_attr(remote_path):
|
|
615
633
|
entries.append((entry.filename, bool(entry.st_mode & stat.S_IFDIR)))
|