remote-cmd-manager 1.2.2__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.2/remote_cmd_manager.egg-info → remote_cmd_manager-2.0.0}/PKG-INFO +1 -1
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/pyproject.toml +4 -1
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/__init__.py +4 -2
- remote_cmd_manager-2.0.0/remote_cmd/_version.py +12 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/cli/main.py +28 -19
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/core/async_connection_pool.py +33 -24
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/core/async_ssh_client.py +20 -15
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/core/host.py +4 -5
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/core/ssh_client.py +41 -23
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/core/sync_connection_pool.py +33 -24
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/repository/json_host_repository.py +11 -12
- {remote_cmd_manager-1.2.2 → 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.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/async_batch_executor.py +25 -42
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/batch_executor.py +39 -140
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/credential_provider.py +11 -5
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/host_service.py +44 -39
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/ssh_service.py +1 -1
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/storage_factory.py +19 -9
- {remote_cmd_manager-1.2.2 → 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.2 → remote_cmd_manager-2.0.0}/remote_cmd/utils/crypto.py +6 -5
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0/remote_cmd_manager.egg-info}/PKG-INFO +1 -1
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/SOURCES.txt +6 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/integration/conftest.py +3 -1
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/integration/test_ssh_connection.py +4 -12
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/performance/conftest.py +1 -3
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/performance/test_benchmarks.py +5 -14
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_async_batch_executor.py +61 -18
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_async_ssh_client.py +5 -11
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_batch_executor.py +43 -3
- remote_cmd_manager-2.0.0/tests/test_pool_policy.py +73 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_ssh_client.py +31 -18
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_storage_factory.py +48 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_sync_connection_pool.py +23 -8
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/LICENSE +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/MANIFEST.in +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/README.md +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/config.example.yaml +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/examples/basic_usage.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/examples/deploy_script.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/examples/nginx_batch_update.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/examples/system_health_check.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/cli/__init__.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/core/__init__.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/core/host_manager.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/repository/__init__.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/repository/host_repository.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/service/__init__.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/utils/__init__.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/utils/config.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/utils/exceptions.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd/utils/logging_utils.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/dependency_links.txt +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/entry_points.txt +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/requires.txt +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/remote_cmd_manager.egg-info/top_level.txt +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/requirements.txt +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/setup.cfg +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/__init__.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/conftest.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/performance/__init__.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_cli.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_config.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_credential_provider.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_crypto.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_host.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_host_manager.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_host_service.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_keyring_provider.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_logging_utils.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_repository.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_sqlite_repository.py +0 -0
- {remote_cmd_manager-1.2.2 → remote_cmd_manager-2.0.0}/tests/test_ssh_service.py +0 -0
- {remote_cmd_manager-1.2.2 → 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
|
|
|
@@ -48,11 +48,13 @@ Remote CMD - SSH 远程服务器管理工具
|
|
|
48
48
|
- 文档: 参见 docs/ 目录
|
|
49
49
|
|
|
50
50
|
Author: Vae-Scrooge
|
|
51
|
-
Version: 1.2.
|
|
51
|
+
Version: 1.2.3
|
|
52
52
|
License: MIT
|
|
53
53
|
"""
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
# 版本号单一真相源(见 remote_cmd._version)
|
|
56
|
+
from remote_cmd._version import __version__
|
|
57
|
+
|
|
56
58
|
__author__ = "Vae-Scrooge"
|
|
57
59
|
__email__ = "vae-scrooge@example.com"
|
|
58
60
|
__license__ = "MIT"
|
|
@@ -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
|
|
@@ -34,6 +35,7 @@ from remote_cmd.service.credential_provider import (
|
|
|
34
35
|
from remote_cmd.service.host_service import HostService
|
|
35
36
|
from remote_cmd.service.storage_factory import build_repository
|
|
36
37
|
from remote_cmd.utils.config import get_default_config_path, load_config
|
|
38
|
+
from remote_cmd.utils.crypto import CredentialEncryption
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
def _build_service(config_file: str, storage_backend: Optional[str] = None) -> HostService:
|
|
@@ -49,7 +51,11 @@ def _build_service(config_file: str, storage_backend: Optional[str] = None) -> H
|
|
|
49
51
|
explicitly, otherwise inferred from the config file extension:
|
|
50
52
|
.json -> JsonHostRepository; .db/.sqlite -> SqliteHostRepository.
|
|
51
53
|
"""
|
|
52
|
-
repo = build_repository(
|
|
54
|
+
repo = build_repository(
|
|
55
|
+
filepath=config_file,
|
|
56
|
+
storage_backend=storage_backend,
|
|
57
|
+
encryption=CredentialEncryption(),
|
|
58
|
+
)
|
|
53
59
|
cred_provider = ChainCredentialProvider(
|
|
54
60
|
[
|
|
55
61
|
EnvCredentialProvider(),
|
|
@@ -72,7 +78,12 @@ def _build_service(config_file: str, storage_backend: Optional[str] = None) -> H
|
|
|
72
78
|
)
|
|
73
79
|
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output mode")
|
|
74
80
|
@click.pass_context
|
|
75
|
-
def cli(
|
|
81
|
+
def cli(
|
|
82
|
+
ctx: click.Context,
|
|
83
|
+
config: Optional[str],
|
|
84
|
+
hosts_file_override: Optional[str],
|
|
85
|
+
verbose: bool,
|
|
86
|
+
) -> None:
|
|
76
87
|
"""
|
|
77
88
|
Remote CMD - SSH remote server management tool
|
|
78
89
|
|
|
@@ -110,7 +121,7 @@ def cli(ctx, config: Optional[str], hosts_file_override: Optional[str], verbose:
|
|
|
110
121
|
|
|
111
122
|
|
|
112
123
|
@cli.group()
|
|
113
|
-
def host():
|
|
124
|
+
def host() -> None:
|
|
114
125
|
"""
|
|
115
126
|
Host management command group
|
|
116
127
|
|
|
@@ -136,7 +147,7 @@ def host():
|
|
|
136
147
|
@click.option("--description", "-d", default="", help="Host description")
|
|
137
148
|
@click.pass_context
|
|
138
149
|
def host_add(
|
|
139
|
-
ctx,
|
|
150
|
+
ctx: click.Context,
|
|
140
151
|
name: str,
|
|
141
152
|
hostname: str,
|
|
142
153
|
username: str,
|
|
@@ -144,7 +155,7 @@ def host_add(
|
|
|
144
155
|
key: Optional[str],
|
|
145
156
|
tag: tuple,
|
|
146
157
|
description: str,
|
|
147
|
-
):
|
|
158
|
+
) -> None:
|
|
148
159
|
"""
|
|
149
160
|
Add a new host
|
|
150
161
|
|
|
@@ -202,7 +213,7 @@ def host_add(
|
|
|
202
213
|
@host.command("list")
|
|
203
214
|
@click.option("--tag", "-t", help="Filter hosts by tag")
|
|
204
215
|
@click.pass_context
|
|
205
|
-
def host_list(ctx, tag: Optional[str]):
|
|
216
|
+
def host_list(ctx: click.Context, tag: Optional[str]) -> None:
|
|
206
217
|
"""List all hosts"""
|
|
207
218
|
service: HostService = ctx.obj["service"]
|
|
208
219
|
hosts = service.list_hosts(tag=tag)
|
|
@@ -228,7 +239,7 @@ def host_list(ctx, tag: Optional[str]):
|
|
|
228
239
|
@click.argument("name", required=True)
|
|
229
240
|
@click.confirmation_option(prompt="Are you sure you want to remove this host?")
|
|
230
241
|
@click.pass_context
|
|
231
|
-
def host_remove(ctx, name: str):
|
|
242
|
+
def host_remove(ctx: click.Context, name: str) -> None:
|
|
232
243
|
"""Remove a host"""
|
|
233
244
|
service: HostService = ctx.obj["service"]
|
|
234
245
|
|
|
@@ -245,7 +256,7 @@ def host_remove(ctx, name: str):
|
|
|
245
256
|
@host.command("show")
|
|
246
257
|
@click.argument("name", required=True)
|
|
247
258
|
@click.pass_context
|
|
248
|
-
def host_show(ctx, name: str):
|
|
259
|
+
def host_show(ctx: click.Context, name: str) -> None:
|
|
249
260
|
"""Show host details"""
|
|
250
261
|
service: HostService = ctx.obj["service"]
|
|
251
262
|
|
|
@@ -267,8 +278,6 @@ def host_show(ctx, name: str):
|
|
|
267
278
|
click.echo(f" Auth: {auth_type}")
|
|
268
279
|
if host.key_filename:
|
|
269
280
|
# Sanitize key path: show only the filename, not full path
|
|
270
|
-
from pathlib import Path
|
|
271
|
-
|
|
272
281
|
key_name = Path(host.key_filename).name
|
|
273
282
|
click.echo(f" Key file: {key_name}")
|
|
274
283
|
tags_str = ", ".join(host.tags) if host.tags else "-"
|
|
@@ -285,7 +294,7 @@ def host_show(ctx, name: str):
|
|
|
285
294
|
@host.command("test")
|
|
286
295
|
@click.argument("name", required=True)
|
|
287
296
|
@click.pass_context
|
|
288
|
-
def host_test(ctx, name: str):
|
|
297
|
+
def host_test(ctx: click.Context, name: str) -> None:
|
|
289
298
|
"""Test host connection"""
|
|
290
299
|
service: HostService = ctx.obj["service"]
|
|
291
300
|
|
|
@@ -302,7 +311,7 @@ def host_test(ctx, name: str):
|
|
|
302
311
|
@click.argument("host_name", required=True)
|
|
303
312
|
@click.argument("command", required=True)
|
|
304
313
|
@click.pass_context
|
|
305
|
-
def run(ctx, host_name: str, command: str):
|
|
314
|
+
def run(ctx: click.Context, host_name: str, command: str) -> None:
|
|
306
315
|
"""
|
|
307
316
|
Execute a command on a remote host
|
|
308
317
|
|
|
@@ -335,7 +344,7 @@ def run(ctx, host_name: str, command: str):
|
|
|
335
344
|
@click.argument("local_path", required=True)
|
|
336
345
|
@click.argument("remote_path", required=True)
|
|
337
346
|
@click.pass_context
|
|
338
|
-
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:
|
|
339
348
|
"""
|
|
340
349
|
Upload a file to a remote host
|
|
341
350
|
|
|
@@ -361,7 +370,7 @@ def upload(ctx, host_name: str, local_path: str, remote_path: str):
|
|
|
361
370
|
@click.argument("local_path", required=True)
|
|
362
371
|
@click.argument("remote_path", required=True)
|
|
363
372
|
@click.pass_context
|
|
364
|
-
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:
|
|
365
374
|
"""
|
|
366
375
|
Download a file from a remote host
|
|
367
376
|
|
|
@@ -393,8 +402,8 @@ def download(ctx, host_name: str, local_path: str, remote_path: str):
|
|
|
393
402
|
@click.option("--show-failures", is_flag=True, help="Show only failed hosts")
|
|
394
403
|
@click.pass_context
|
|
395
404
|
def batch_run(
|
|
396
|
-
ctx,
|
|
397
|
-
host_names: tuple,
|
|
405
|
+
ctx: click.Context,
|
|
406
|
+
host_names: tuple[str, ...],
|
|
398
407
|
command: str,
|
|
399
408
|
concurrency: int,
|
|
400
409
|
timeout: int,
|
|
@@ -402,7 +411,7 @@ def batch_run(
|
|
|
402
411
|
retry_delay: float,
|
|
403
412
|
use_async: bool,
|
|
404
413
|
show_failures: bool,
|
|
405
|
-
):
|
|
414
|
+
) -> None:
|
|
406
415
|
"""
|
|
407
416
|
Execute a command on multiple hosts in batch
|
|
408
417
|
|
|
@@ -438,7 +447,7 @@ def batch_run(
|
|
|
438
447
|
show_percent=True,
|
|
439
448
|
) as bar:
|
|
440
449
|
|
|
441
|
-
def progress(_completed, _total, _host_name):
|
|
450
|
+
def progress(_completed: int, _total: int, _host_name: str) -> None:
|
|
442
451
|
bar.update(1)
|
|
443
452
|
|
|
444
453
|
result = executor.execute(
|
|
@@ -483,7 +492,7 @@ def batch_run(
|
|
|
483
492
|
ctx.exit(1)
|
|
484
493
|
|
|
485
494
|
|
|
486
|
-
def main():
|
|
495
|
+
def main() -> None:
|
|
487
496
|
"""CLI entry point"""
|
|
488
497
|
cli()
|
|
489
498
|
|
{remote_cmd_manager-1.2.2 → 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)))
|