remote_cmd

Remote CMD - SSH 远程服务器管理工具

一个功能强大的 Python 库,用于管理远程服务器。 提供简洁的 API 用于 SSH 连接、命令执行和文件传输。

主要功能: - SSH 连接管理(支持密码和密钥认证) - 远程命令执行(包括 sudo 命令) - 文件上传和下载 - 主机配置管理 - 标签分类系统 - 批量连接测试 - 凭据加密存储 - 结构化日志系统

快速开始:

from remote_cmd import SSHClient from remote_cmd.core.ssh_client import ConnectionConfig

创建连接配置

config = ConnectionConfig( ... hostname="192.168.1.100", ... username="admin", ... key_filename="~/.ssh/id_rsa" ... )

执行远程命令

with SSHClient(config) as client: ... result = client.execute("ls -la") ... print(result.stdout)

新架构(推荐):

from remote_cmd.repository.json_host_repository import JsonHostRepository from remote_cmd.service.host_service import HostService

repo = JsonHostRepository("hosts.json") service = HostService(repo) service.add_host(host)

命令行使用: $ remote-cmd host add server1 192.168.1.100 admin -k ~/.ssh/id_rsa $ remote-cmd host list $ remote-cmd run server1 "uptime"

更多信息: - GitHub: https://github.com/Vae-Scrooge/remote-cmd - 文档: 参见 docs/ 目录

Author: Vae-Scrooge Version: 2.1.0(单一真相源见 remote_cmd._version) License: MIT

  1"""
  2Remote CMD - SSH 远程服务器管理工具
  3
  4一个功能强大的 Python 库,用于管理远程服务器。
  5提供简洁的 API 用于 SSH 连接、命令执行和文件传输。
  6
  7主要功能:
  8    - SSH 连接管理(支持密码和密钥认证)
  9    - 远程命令执行(包括 sudo 命令)
 10    - 文件上传和下载
 11    - 主机配置管理
 12    - 标签分类系统
 13    - 批量连接测试
 14    - 凭据加密存储
 15    - 结构化日志系统
 16
 17快速开始:
 18    >>> from remote_cmd import SSHClient
 19    >>> from remote_cmd.core.ssh_client import ConnectionConfig
 20    >>>
 21    >>> # 创建连接配置
 22    >>> config = ConnectionConfig(
 23    ...     hostname="192.168.1.100",
 24    ...     username="admin",
 25    ...     key_filename="~/.ssh/id_rsa"
 26    ... )
 27    >>>
 28    >>> # 执行远程命令
 29    >>> with SSHClient(config) as client:
 30    ...     result = client.execute("ls -la")
 31    ...     print(result.stdout)
 32
 33新架构(推荐):
 34    >>> from remote_cmd.repository.json_host_repository import JsonHostRepository
 35    >>> from remote_cmd.service.host_service import HostService
 36    >>>
 37    >>> repo = JsonHostRepository("hosts.json")
 38    >>> service = HostService(repo)
 39    >>> service.add_host(host)
 40
 41命令行使用:
 42    $ remote-cmd host add server1 192.168.1.100 admin -k ~/.ssh/id_rsa
 43    $ remote-cmd host list
 44    $ remote-cmd run server1 "uptime"
 45
 46更多信息:
 47    - GitHub: https://github.com/Vae-Scrooge/remote-cmd
 48    - 文档: 参见 docs/ 目录
 49
 50Author: Vae-Scrooge
 51Version: 2.1.0(单一真相源见 remote_cmd._version)
 52License: MIT
 53"""
 54
 55# 版本号单一真相源(见 remote_cmd._version)
 56from remote_cmd._version import __version__
 57
 58__author__ = "Vae-Scrooge"
 59__email__ = "vae-scrooge@example.com"
 60__license__ = "MIT"
 61
 62import logging
 63
 64logging.getLogger(__name__).addHandler(logging.NullHandler())
 65
 66# 异步原生模块依赖 asyncssh(optional extra [async]),未安装时优雅降级,
 67# 避免 `import remote_cmd` 直接失败
 68try:
 69    from remote_cmd.core.async_connection_pool import AsyncConnectionPool
 70    from remote_cmd.core.async_ssh_client import AsyncSSHClient
 71    from remote_cmd.service.async_batch_executor import AsyncBatchExecutor
 72
 73    _HAS_ASYNC = True
 74except ImportError:  # pragma: no cover - 依赖 asyncssh,未安装时不导出异步符号
 75    _HAS_ASYNC = False
 76
 77from remote_cmd.core.host import Host
 78from remote_cmd.core.host_manager import HostManager
 79from remote_cmd.core.ssh_client import SSHClient
 80from remote_cmd.core.sync_connection_pool import SyncConnectionPool
 81
 82if _HAS_ASYNC:
 83    # 兼容别名(历史名称)
 84    NativeAsyncSSHClient = AsyncSSHClient
 85    ConnectionPool = AsyncConnectionPool
 86
 87# 新架构导出(推荐)
 88from remote_cmd.repository import HostRepository, JsonHostRepository
 89
 90# Phase 2 新组件
 91from remote_cmd.repository.sqlite_host_repository import SqliteHostRepository
 92from remote_cmd.service import (
 93    ChainCredentialProvider,
 94    CredentialProvider,
 95    EnvCredentialProvider,
 96    HostService,
 97    SSHService,
 98)
 99from remote_cmd.service.batch_executor import BatchExecutor, BatchHostResult, BatchResult
100from remote_cmd.service.credential_provider import KeyringCredentialProvider
101from remote_cmd.service.task_runner import Task, TaskRunner, TaskStatus
102from remote_cmd.utils.crypto import CredentialEncryption
103from remote_cmd.utils.logging_utils import (
104    SensitiveDataFilter,
105    get_logger,
106    setup_logging,
107)
108
109if _HAS_ASYNC:
110    __all__ = [
111        # 原有导出(向后兼容)
112        "SSHClient",
113        "AsyncSSHClient",
114        "ConnectionPool",
115        "NativeAsyncSSHClient",
116        "AsyncConnectionPool",
117        "AsyncBatchExecutor",
118        "Host",
119        "HostManager",
120        # 新架构导出
121        "HostRepository",
122        "JsonHostRepository",
123        "HostService",
124        "SSHService",
125        "CredentialProvider",
126        "EnvCredentialProvider",
127        "ChainCredentialProvider",
128        "CredentialEncryption",
129        "setup_logging",
130        "SensitiveDataFilter",
131        "get_logger",
132        # Phase 2 新组件
133        "SqliteHostRepository",
134        "BatchExecutor",
135        "BatchResult",
136        "BatchHostResult",
137        "SyncConnectionPool",
138        "TaskRunner",
139        "Task",
140        "TaskStatus",
141        "KeyringCredentialProvider",
142        # 元信息
143        "__version__",
144        "__author__",
145        "__license__",
146    ]
147else:
148    __all__ = [
149        # 原有导出(向后兼容,不含异步符号)
150        "SSHClient",
151        "Host",
152        "HostManager",
153        # 新架构导出
154        "HostRepository",
155        "JsonHostRepository",
156        "HostService",
157        "SSHService",
158        "CredentialProvider",
159        "EnvCredentialProvider",
160        "ChainCredentialProvider",
161        "CredentialEncryption",
162        "setup_logging",
163        "SensitiveDataFilter",
164        "get_logger",
165        # Phase 2 新组件
166        "SqliteHostRepository",
167        "BatchExecutor",
168        "BatchResult",
169        "BatchHostResult",
170        "SyncConnectionPool",
171        "TaskRunner",
172        "Task",
173        "TaskStatus",
174        "KeyringCredentialProvider",
175        # 元信息
176        "__version__",
177        "__author__",
178        "__license__",
179    ]
class SSHClient:
208class SSHClient:
209    """
210    高级 SSH 客户端类
211
212    提供完整的 SSH 连接管理功能,支持上下文管理器模式,
213    可以使用 `with` 语句自动管理连接的生命周期。
214
215    主要功能:
216    - 建立/断开 SSH 连接
217    - 执行远程命令(普通命令和 sudo 命令)
218    - 文件上传/下载
219    - 远程目录浏览
220
221    使用示例:
222        >>> config = ConnectionConfig(
223        ...     hostname="example.com",
224        ...     username="admin",
225        ...     key_filename="~/.ssh/id_rsa"
226        ... )
227        >>> with SSHClient(config) as client:
228        ...     result = client.execute("ls -la")
229        ...     print(result.stdout)
230    """
231
232    def __init__(self, config: ConnectionConfig) -> None:
233        """
234        初始化 SSH 客户端
235
236        Args:
237            config: ConnectionConfig 对象,包含连接参数
238
239        Note:
240            初始化时不会建立连接,需要调用 connect() 方法或使用上下文管理器
241        """
242        self.config = config
243        self._client: Optional[paramiko.SSHClient] = None
244        self._sftp: Optional[paramiko.SFTPClient] = None
245
246    # ========================================================================
247    # 连接管理方法
248    # ========================================================================
249
250    def connect(self) -> "SSHClient":
251        """
252        建立 SSH 连接
253
254        根据配置信息建立到远程服务器的 SSH 连接。
255        支持密码认证和密钥认证两种方式。
256
257        Returns:
258            SSHClient: 返回自身,支持链式调用
259
260        Raises:
261            SSHConnectionError: 连接失败时抛出,包括:
262                - 认证失败
263                - 连接超时
264                - 主机无法解析
265                - 其他网络错误
266
267        Example:
268            >>> client = SSHClient(config)
269            >>> client.connect()  # 建立连接
270            >>> # 或链式调用
271            >>> client.connect().execute("ls")
272        """
273        try:
274            # 创建 SSH 客户端实例
275            self._client = paramiko.SSHClient()
276
277            # 设置主机密钥策略
278            policy = self.config.host_key_policy or paramiko.RejectPolicy()
279            if isinstance(policy, paramiko.AutoAddPolicy):
280                logger.warning(_SECURITY_WARNING_AUTOADD)
281            self._client.set_missing_host_key_policy(policy)
282
283            # 加载 known_hosts 文件(可选)
284            known_hosts = self.config.known_hosts_file
285            if known_hosts:
286                known_hosts_path = Path(known_hosts).expanduser()
287                if known_hosts_path.exists():
288                    self._client.load_host_keys(str(known_hosts_path))
289                    logger.debug(f"loaded known_hosts: {known_hosts_path}")
290                else:
291                    logger.warning(f"known_hosts file not found: {known_hosts_path}")
292
293            # 构建连接参数字典
294            connect_kwargs = {
295                "hostname": self.config.hostname,
296                "port": self.config.port,
297                "username": self.config.username,
298                "timeout": self.config.timeout,
299                "compress": self.config.compress,
300            }
301
302            # 根据认证方式添加相应参数
303            if self.config.password:
304                # 密码认证
305                connect_kwargs["password"] = self.config.password
306            elif self.config.key_filename:
307                # 密钥认证:展开 ~ 并验证文件存在
308                key_path = Path(self.config.key_filename).expanduser()
309                if not key_path.exists():
310                    raise SSHConnectionError(f"SSH key file not found: {key_path}")
311                connect_kwargs["key_filename"] = str(key_path)
312
313            # 记录连接日志
314            logger.info(f"connecting to {self.config.hostname}:{self.config.port}")
315
316            # 建立连接
317            self._client.connect(**connect_kwargs)
318            logger.info(f"connected to {self.config.hostname}")
319
320            return self
321
322        except paramiko.AuthenticationException as e:
323            # 永久性错误:重试同一凭据只会加剧账号锁定(见 service/retry_policy.py)
324            raise SSHAuthenticationError(f"authentication failed: {e}") from e
325        except socket.timeout as e:
326            raise SSHTimeoutError(f"connection timeout: {self.config.hostname}") from e
327        except socket.gaierror as e:
328            raise SSHConnectionError(f"could not resolve hostname: {self.config.hostname}") from e
329        except (OSError, paramiko.SSHException) as e:
330            raise SSHConnectionError(f"connection error: {e}") from e
331
332    def disconnect(self) -> None:
333        """
334        断开 SSH 连接并清理资源
335
336        关闭 SFTP 和 SSH 连接,释放所有相关资源。
337        即使连接已断开或出现错误,此方法也能安全执行。
338        """
339        # 关闭 SFTP 连接
340        if self._sftp:
341            try:
342                self._sftp.close()
343                logger.debug("SFTP connection closed")
344            except (OSError, paramiko.SSHException) as e:
345                logger.warning(f"error closing SFTP connection: {e}")
346            finally:
347                self._sftp = None
348
349        # 关闭 SSH 连接
350        if self._client:
351            try:
352                self._client.close()
353                logger.debug("SSH connection closed")
354            except (OSError, paramiko.SSHException) as e:
355                logger.warning(f"error closing SSH connection: {e}")
356            finally:
357                self._client = None
358
359    def is_connected(self) -> bool:
360        """
361        检查 SSH 连接是否处于活动状态
362
363        Returns:
364            bool: 连接活动返回 True,否则返回 False
365        """
366        if not self._client:
367            return False
368
369        try:
370            transport = self._client.get_transport()
371            return transport is not None and transport.is_active()
372        except (AttributeError, OSError):
373            return False
374
375    def _read_output(
376        self,
377        stdout: Any,
378        stderr: Any,
379        timeout: Optional[int],
380    ) -> tuple[int, str, str]:
381        """并发排空命令输出流并返回 (exit_code, stdout, stderr)。
382
383        大输出死锁防护(paramiko 官方文档对 ``recv_exit_status`` 的警告
384        场景):SSH 通道窗口(默认 2MB)限制远端可发送的未确认数据量。
385        若在排空输出流之前等待退出状态、或只阻塞读取其中一流,远端写满
386        窗口后会阻塞,命令永不退出 → 死锁。因此:
387
388        - stderr 由后台线程排空、stdout 在当前线程读取,两流并发消费,
389          窗口持续调整,远端永远不会因窗口耗尽而卡死;
390        - 两个流都读到 EOF(命令已退出)后再取退出状态,此时立即返回。
391
392        超时语义(wall-clock,与 AsyncSSHClient 的 ``conn.run(timeout=...)``
393        对齐):不使用通道 ``settimeout``(其 per-recv 语义会误杀"长时间
394        静默于单一流但整体健康"的命令),改由定时器在超时后关闭通道——
395        关闭使两个阻塞读取解除(返回已缓冲数据),且 ``_set_closed`` 会
396        置位 status_event 使 ``recv_exit_status`` 立即返回,不会二次挂起。
397
398        Args:
399            stdout: exec_command 返回的 stdout 文件对象
400            stderr: exec_command 返回的 stderr 文件对象
401            timeout: wall-clock 超时(秒),None 表示不限时
402
403        Returns:
404            tuple[int, str, str]: (exit_code, stdout_text, stderr_text)
405
406        Raises:
407            SSHCommandTimeoutError: 命令在 timeout 内未完成
408        """
409        channel = stdout.channel
410        stderr_bytes = b""
411        stderr_error: Optional[BaseException] = None
412
413        def _drain_stderr() -> None:
414            nonlocal stderr_bytes, stderr_error
415            try:
416                stderr_bytes = stderr.read()
417            except BaseException as e:  # noqa: BLE001 - 线程内异常回传主线程
418                stderr_error = e
419
420        reader = threading.Thread(target=_drain_stderr, name="ssh-stderr-drain", daemon=True)
421
422        timed_out = threading.Event()
423
424        def _on_timeout() -> None:
425            timed_out.set()
426            # 关闭通道以终止远端命令,并解除两个读取的阻塞
427            with contextlib.suppress(Exception):
428                channel.close()
429
430        timer: Optional[threading.Timer] = None
431        if timeout is not None:
432            timer = threading.Timer(timeout, _on_timeout)
433            timer.daemon = True
434            timer.start()
435
436        try:
437            reader.start()
438            stdout_bytes = stdout.read()
439        finally:
440            if timer is not None:
441                timer.cancel()
442            # 有界 join:正常路径通道关闭后 reader 立即返回;极端场景下
443            # (close 失败 / 主线程读取异常而通道未关闭)reader 可能仍
444            # 阻塞在远端输出上——放弃等待其自然退出,避免调用方永久挂起
445            reader.join(timeout=_READER_JOIN_TIMEOUT)
446            if reader.is_alive():
447                logger.debug(
448                    "stderr drain thread did not finish within %.1fs", _READER_JOIN_TIMEOUT
449                )
450
451        if timed_out.is_set():
452            raise SSHCommandTimeoutError(f"command timed out after {timeout} seconds")
453        if stderr_error is not None:
454            raise stderr_error
455
456        exit_code = channel.recv_exit_status()
457        return (
458            exit_code,
459            stdout_bytes.decode("utf-8", errors="replace"),
460            stderr_bytes.decode("utf-8", errors="replace"),
461        )
462
463    def _get_sftp(self) -> paramiko.SFTPClient:
464        """获取 SFTP 客户端(延迟初始化)"""
465        if not self._client:
466            raise SSHConnectionError("not connected, call connect() first")
467        if not self._sftp:
468            self._sftp = self._client.open_sftp()
469        return self._sftp
470
471    # ========================================================================
472    # 上下文管理器支持
473    # ========================================================================
474
475    def __enter__(self) -> "SSHClient":
476        """
477        上下文管理器入口:自动建立连接
478
479        Returns:
480            SSHClient: 已连接的客户端实例
481        """
482        return self.connect()
483
484    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
485        """
486        上下文管理器出口:自动断开连接
487
488        Args:
489            exc_type: 异常类型
490            exc_val: 异常值
491            exc_tb: 异常追踪信息
492        """
493        self.disconnect()
494
495    # ========================================================================
496    # 命令执行方法
497    # ========================================================================
498
499    def execute(
500        self,
501        command: str,
502        timeout: Optional[int] = None,
503        environment: Optional[dict[str, str]] = None,
504    ) -> CommandResult:
505        """
506        在远程服务器上执行命令
507
508        Args:
509            command: 要执行的命令字符串
510            timeout: 命令执行 wall-clock 超时时间(秒),None 表示不限时。
511                超时后关闭通道终止远端命令并抛出 SSHCommandTimeoutError。
512                输出流在内部并发排空,大输出(超过 SSH 通道窗口)不会死锁
513            environment: 环境变量字典,将在命令执行前设置
514
515        Returns:
516            CommandResult: 包含命令执行结果的对象
517
518        Raises:
519            SSHCommandError: 命令执行失败时抛出
520            SSHConnectionError: 未连接时抛出
521
522        Example:
523            >>> result = client.execute("ls -la")
524            >>> if result.success:
525            ...     print(result.stdout)
526        """
527        # 检查连接状态
528        if not self._client:
529            raise SSHConnectionError("not connected, call connect() first")
530
531        # 安全:键必须为合法 shell 标识符(值虽已转义,键直接拼入命令)
532        validate_environment(environment)
533
534        try:
535            # 安全:不记录命令全文(可能含敏感参数),仅记录执行事件
536            logger.debug("executing remote command")
537
538            # 构建环境变量设置命令
539            # 安全:对 value 做 shlex.quote 转义,防止包含 shell 元字符
540            # (如 ;、$()、反引号)的值触发命令注入或带空格的值静默失败
541            env_str = ""
542            if environment:
543                env_vars = [f"export {k}={shlex.quote(str(v))}" for k, v in environment.items()]
544                env_str = "; ".join(env_vars) + "; "
545
546            # 组合完整命令(切换到用户主目录执行)
547            full_command = f"{env_str}cd ~ && {command}"
548
549            # 执行命令(timeout 为 wall-clock 语义,由 _read_output 实施:
550            # 并发排空两流防大输出死锁,超时关闭通道终止远端命令)
551            stdin, stdout, stderr = self._client.exec_command(full_command)
552
553            # 获取命令执行结果(先排空输出流,再取退出状态)
554            exit_code, stdout_data, stderr_data = self._read_output(stdout, stderr, timeout)
555
556            # 构建结果对象
557            result = CommandResult(
558                command=command,
559                stdout=stdout_data,
560                stderr=stderr_data,
561                exit_code=exit_code,
562            )
563
564            logger.debug(f"command finished, exit code: {exit_code}")
565            return result
566
567        except (paramiko.SSHException, OSError) as e:
568            raise SSHCommandError(f"command execution failed: {e}") from e
569
570    def execute_sudo(
571        self,
572        command: str,
573        password: Optional[str] = None,
574        timeout: Optional[int] = None,
575    ) -> CommandResult:
576        """
577        以 sudo 权限执行命令(安全实现)
578
579        Args:
580            command: 要执行的命令字符串(不需要包含 sudo 前缀)
581            password: sudo 密码(如果需要),None 表示使用无密码 sudo
582            timeout: 命令执行超时时间(秒)
583
584        Returns:
585            CommandResult: 包含命令执行结果的对象
586
587        Note:
588            - 如果提供了 password,使用 exec_command + -S 从 stdin 传入密码
589            - 密码不会出现在进程列表或日志中
590            - stdout 和 stderr 保持独立分离
591
592        Example:
593            >>> result = client.execute_sudo("systemctl restart nginx", password="mypass")
594        """
595        if not self._client:
596            raise SSHConnectionError("not connected, call connect() first")
597
598        if password is None:
599            full_command = f"sudo {command}"
600            return self.execute(full_command, timeout)
601
602        # 使用 exec_command + sudo -S 从 stdin 传入密码,保持 stdout/stderr 分离
603        try:
604            full_command = f"sudo -S {command}"
605            # get_pty=False:避免 PTY 合并 stdout/stderr(与文档"独立分离"一致),
606            # 同时关闭 PTY echo 防止 sudo 密码被回显到 stdout 造成凭据泄露
607            stdin, stdout, stderr = self._client.exec_command(full_command, get_pty=False)
608            stdin.write(password + "\n")
609            stdin.flush()
610
611            # 与 execute 一致:先并发排空两流(防大输出死锁),再取退出状态;
612            # timeout 为 wall-clock 语义
613            exit_code, stdout_data, stderr_data = self._read_output(stdout, stderr, timeout)
614
615            return CommandResult(
616                command=command,
617                stdout=stdout_data,
618                stderr=stderr_data,
619                exit_code=exit_code,
620            )
621        except (paramiko.SSHException, OSError) as e:
622            raise SSHCommandError(f"sudo command execution failed: {e}") from e
623
624    # ========================================================================
625    # 文件传输方法
626    # ========================================================================
627
628    def upload_file(self, local_path: str, remote_path: str) -> None:
629        """
630        上传本地文件到远程服务器
631
632        Args:
633            local_path: 本地文件路径
634            remote_path: 远程目标路径(绝对路径)
635
636        Raises:
637            SSHFileTransferError: 文件传输失败时抛出
638            SSHConnectionError: 未连接时抛出
639
640        Example:
641            >>> client.upload_file("./script.sh", "/home/user/script.sh")
642        """
643        sftp = self._get_sftp()
644
645        # 验证本地文件存在
646        local_file = Path(local_path)
647        if not local_file.exists():
648            raise SSHFileTransferError(f"Local file not found: {local_path}")
649
650        # 执行上传
651        try:
652            logger.info(f"uploading file: {local_path} -> {remote_path}")
653            sftp.put(str(local_file), remote_path)
654            logger.info("file upload finished")
655        except (paramiko.SSHException, OSError) as e:
656            raise SSHFileTransferError(f"file upload failed: {e}") from e
657
658    def download_file(self, remote_path: str, local_path: str) -> None:
659        """
660        从远程服务器下载文件到本地
661
662        Args:
663            remote_path: 远程文件路径(绝对路径)
664            local_path: 本地目标路径
665
666        Raises:
667            SSHFileTransferError: 文件传输失败时抛出
668            SSHConnectionError: 未连接时抛出
669
670        Note:
671            如果本地目录不存在,将自动创建
672
673        Example:
674            >>> client.download_file("/var/log/syslog", "./logs/syslog")
675        """
676        sftp = self._get_sftp()
677
678        # 确保本地目录存在
679        local_file = Path(local_path)
680        local_file.parent.mkdir(parents=True, exist_ok=True)
681
682        # 执行下载
683        try:
684            logger.info(f"downloading file: {remote_path} -> {local_path}")
685            sftp.get(remote_path, str(local_file))
686            logger.info("file download finished")
687        except (paramiko.SSHException, OSError) as e:
688            raise SSHFileTransferError(f"file download failed: {e}") from e
689
690    def list_remote_directory(self, remote_path: str = ".") -> list[RemoteFileEntry]:
691        """
692        列出远程目录内容
693
694        Args:
695            remote_path: 远程目录路径,默认为当前目录
696
697        Returns:
698            List[RemoteFileEntry]: 目录项信息列表
699
700        Raises:
701            SSHFileTransferError: 列出目录失败时抛出
702            SSHConnectionError: 未连接时抛出
703
704        Example:
705            >>> entries = client.list_remote_directory("/home/user")
706            >>> for entry in entries:
707            ...     print(f"{entry.name}: {entry.size} bytes")
708        """
709        sftp = self._get_sftp()
710
711        try:
712            entries: list[RemoteFileEntry] = []
713            for entry in sftp.listdir_attr(remote_path):
714                mode = entry.st_mode if entry.st_mode is not None else 0
715                entries.append(
716                    RemoteFileEntry(
717                        name=entry.filename,
718                        size=entry.st_size,
719                        mode=oct(mode)[-3:] if mode else "000",
720                        mtime=entry.st_mtime,
721                        is_dir=bool(mode & stat.S_IFDIR) if mode else False,
722                    )
723                )
724            return entries
725        except (paramiko.SSHException, OSError) as e:
726            raise SSHFileTransferError(f"failed to list remote directory: {e}") from e
727
728    def create_remote_directory(self, path: str) -> None:
729        """创建远程目录(支持递归创建)"""
730        sftp = self._get_sftp()
731
732        def _makedirs(sftp_client: paramiko.SFTPClient, remote_path: str) -> None:
733            if remote_path == "/":
734                return
735            try:
736                sftp_client.stat(remote_path)
737            except OSError:
738                _makedirs(sftp_client, str(Path(remote_path).parent))
739                sftp_client.mkdir(remote_path)
740
741        try:
742            _makedirs(sftp, path)
743            logger.info(f"created remote directory: {path}")
744        except (paramiko.SSHException, OSError) as e:
745            raise SSHFileTransferError(f"failed to create remote directory: {e}") from e
746
747    def remove_remote_file(self, path: str) -> None:
748        """删除远程文件"""
749        sftp = self._get_sftp()
750        try:
751            sftp.remove(path)
752            logger.info(f"deleted remote file: {path}")
753        except (paramiko.SSHException, OSError) as e:
754            raise SSHFileTransferError(f"failed to delete remote file: {e}") from e
755
756    def remove_remote_directory(self, path: str, recursive: bool = False) -> None:
757        """删除远程目录"""
758        sftp = self._get_sftp()
759
760        def _rm_recursive(sftp_client: paramiko.SFTPClient, remote_path: str) -> None:
761            """递归删除目录内容,先收集后删除以避免不一致状态"""
762            entries: list[tuple[str, bool]] = []
763            try:
764                for entry in sftp_client.listdir_attr(remote_path):
765                    entries.append((entry.filename, bool(entry.st_mode & stat.S_IFDIR)))
766            except OSError:
767                return
768            # 先删除文件,再递归删除子目录
769            for name, is_dir in entries:
770                full_path = f"{remote_path}/{name}"
771                if is_dir:
772                    _rm_recursive(sftp_client, full_path)
773                else:
774                    sftp_client.remove(full_path)
775            sftp_client.rmdir(remote_path)
776
777        try:
778            if recursive:
779                _rm_recursive(sftp, path)
780            else:
781                sftp.rmdir(path)
782            logger.info(f"deleted remote directory: {path}")
783        except (paramiko.SSHException, OSError) as e:
784            raise SSHFileTransferError(f"failed to delete remote directory: {e}") from e
785
786    def remote_file_exists(self, path: str) -> bool:
787        """检查远程文件是否存在"""
788        try:
789            sftp = self._get_sftp()
790            sftp.stat(path)
791            return True
792        except OSError:
793            return False
794        except SSHConnectionError:
795            return False
796
797    def get_remote_file_info(self, path: str) -> dict[str, Any]:
798        """获取远程文件信息"""
799        sftp = self._get_sftp()
800        try:
801            stat_result = sftp.stat(path)
802            mode = stat_result.st_mode
803            return {
804                "name": Path(path).name,
805                "size": stat_result.st_size,
806                "mode": oct(mode)[-3:] if mode else "000",
807                "mtime": stat_result.st_mtime,
808                "is_dir": stat.S_ISDIR(mode),
809                "is_file": stat.S_ISREG(mode),
810            }
811        except (paramiko.SSHException, OSError) as e:
812            raise SSHFileTransferError(f"failed to get file info: {e}") from e

高级 SSH 客户端类

提供完整的 SSH 连接管理功能,支持上下文管理器模式, 可以使用 with 语句自动管理连接的生命周期。

主要功能:

  • 建立/断开 SSH 连接
  • 执行远程命令(普通命令和 sudo 命令)
  • 文件上传/下载
  • 远程目录浏览

使用示例:

config = ConnectionConfig( ... hostname="example.com", ... username="admin", ... key_filename="~/.ssh/id_rsa" ... ) with SSHClient(config) as client: ... result = client.execute("ls -la") ... print(result.stdout)

SSHClient(config: remote_cmd.core.ssh_client.ConnectionConfig)
232    def __init__(self, config: ConnectionConfig) -> None:
233        """
234        初始化 SSH 客户端
235
236        Args:
237            config: ConnectionConfig 对象,包含连接参数
238
239        Note:
240            初始化时不会建立连接,需要调用 connect() 方法或使用上下文管理器
241        """
242        self.config = config
243        self._client: Optional[paramiko.SSHClient] = None
244        self._sftp: Optional[paramiko.SFTPClient] = None

初始化 SSH 客户端

Args: config: ConnectionConfig 对象,包含连接参数

Note: 初始化时不会建立连接,需要调用 connect() 方法或使用上下文管理器

config
def connect(self) -> 'SSHClient':
250    def connect(self) -> "SSHClient":
251        """
252        建立 SSH 连接
253
254        根据配置信息建立到远程服务器的 SSH 连接。
255        支持密码认证和密钥认证两种方式。
256
257        Returns:
258            SSHClient: 返回自身,支持链式调用
259
260        Raises:
261            SSHConnectionError: 连接失败时抛出,包括:
262                - 认证失败
263                - 连接超时
264                - 主机无法解析
265                - 其他网络错误
266
267        Example:
268            >>> client = SSHClient(config)
269            >>> client.connect()  # 建立连接
270            >>> # 或链式调用
271            >>> client.connect().execute("ls")
272        """
273        try:
274            # 创建 SSH 客户端实例
275            self._client = paramiko.SSHClient()
276
277            # 设置主机密钥策略
278            policy = self.config.host_key_policy or paramiko.RejectPolicy()
279            if isinstance(policy, paramiko.AutoAddPolicy):
280                logger.warning(_SECURITY_WARNING_AUTOADD)
281            self._client.set_missing_host_key_policy(policy)
282
283            # 加载 known_hosts 文件(可选)
284            known_hosts = self.config.known_hosts_file
285            if known_hosts:
286                known_hosts_path = Path(known_hosts).expanduser()
287                if known_hosts_path.exists():
288                    self._client.load_host_keys(str(known_hosts_path))
289                    logger.debug(f"loaded known_hosts: {known_hosts_path}")
290                else:
291                    logger.warning(f"known_hosts file not found: {known_hosts_path}")
292
293            # 构建连接参数字典
294            connect_kwargs = {
295                "hostname": self.config.hostname,
296                "port": self.config.port,
297                "username": self.config.username,
298                "timeout": self.config.timeout,
299                "compress": self.config.compress,
300            }
301
302            # 根据认证方式添加相应参数
303            if self.config.password:
304                # 密码认证
305                connect_kwargs["password"] = self.config.password
306            elif self.config.key_filename:
307                # 密钥认证:展开 ~ 并验证文件存在
308                key_path = Path(self.config.key_filename).expanduser()
309                if not key_path.exists():
310                    raise SSHConnectionError(f"SSH key file not found: {key_path}")
311                connect_kwargs["key_filename"] = str(key_path)
312
313            # 记录连接日志
314            logger.info(f"connecting to {self.config.hostname}:{self.config.port}")
315
316            # 建立连接
317            self._client.connect(**connect_kwargs)
318            logger.info(f"connected to {self.config.hostname}")
319
320            return self
321
322        except paramiko.AuthenticationException as e:
323            # 永久性错误:重试同一凭据只会加剧账号锁定(见 service/retry_policy.py)
324            raise SSHAuthenticationError(f"authentication failed: {e}") from e
325        except socket.timeout as e:
326            raise SSHTimeoutError(f"connection timeout: {self.config.hostname}") from e
327        except socket.gaierror as e:
328            raise SSHConnectionError(f"could not resolve hostname: {self.config.hostname}") from e
329        except (OSError, paramiko.SSHException) as e:
330            raise SSHConnectionError(f"connection error: {e}") from e

建立 SSH 连接

根据配置信息建立到远程服务器的 SSH 连接。 支持密码认证和密钥认证两种方式。

Returns: SSHClient: 返回自身,支持链式调用

Raises: SSHConnectionError: 连接失败时抛出,包括: - 认证失败 - 连接超时 - 主机无法解析 - 其他网络错误

Example:

client = SSHClient(config) client.connect() # 建立连接

或链式调用

client.connect().execute("ls")

def disconnect(self) -> None:
332    def disconnect(self) -> None:
333        """
334        断开 SSH 连接并清理资源
335
336        关闭 SFTP 和 SSH 连接,释放所有相关资源。
337        即使连接已断开或出现错误,此方法也能安全执行。
338        """
339        # 关闭 SFTP 连接
340        if self._sftp:
341            try:
342                self._sftp.close()
343                logger.debug("SFTP connection closed")
344            except (OSError, paramiko.SSHException) as e:
345                logger.warning(f"error closing SFTP connection: {e}")
346            finally:
347                self._sftp = None
348
349        # 关闭 SSH 连接
350        if self._client:
351            try:
352                self._client.close()
353                logger.debug("SSH connection closed")
354            except (OSError, paramiko.SSHException) as e:
355                logger.warning(f"error closing SSH connection: {e}")
356            finally:
357                self._client = None

断开 SSH 连接并清理资源

关闭 SFTP 和 SSH 连接,释放所有相关资源。 即使连接已断开或出现错误,此方法也能安全执行。

def is_connected(self) -> bool:
359    def is_connected(self) -> bool:
360        """
361        检查 SSH 连接是否处于活动状态
362
363        Returns:
364            bool: 连接活动返回 True,否则返回 False
365        """
366        if not self._client:
367            return False
368
369        try:
370            transport = self._client.get_transport()
371            return transport is not None and transport.is_active()
372        except (AttributeError, OSError):
373            return False

检查 SSH 连接是否处于活动状态

Returns: bool: 连接活动返回 True,否则返回 False

def execute( self, command: str, timeout: int | None = None, environment: dict[str, str] | None = None) -> remote_cmd.core.ssh_client.CommandResult:
499    def execute(
500        self,
501        command: str,
502        timeout: Optional[int] = None,
503        environment: Optional[dict[str, str]] = None,
504    ) -> CommandResult:
505        """
506        在远程服务器上执行命令
507
508        Args:
509            command: 要执行的命令字符串
510            timeout: 命令执行 wall-clock 超时时间(秒),None 表示不限时。
511                超时后关闭通道终止远端命令并抛出 SSHCommandTimeoutError。
512                输出流在内部并发排空,大输出(超过 SSH 通道窗口)不会死锁
513            environment: 环境变量字典,将在命令执行前设置
514
515        Returns:
516            CommandResult: 包含命令执行结果的对象
517
518        Raises:
519            SSHCommandError: 命令执行失败时抛出
520            SSHConnectionError: 未连接时抛出
521
522        Example:
523            >>> result = client.execute("ls -la")
524            >>> if result.success:
525            ...     print(result.stdout)
526        """
527        # 检查连接状态
528        if not self._client:
529            raise SSHConnectionError("not connected, call connect() first")
530
531        # 安全:键必须为合法 shell 标识符(值虽已转义,键直接拼入命令)
532        validate_environment(environment)
533
534        try:
535            # 安全:不记录命令全文(可能含敏感参数),仅记录执行事件
536            logger.debug("executing remote command")
537
538            # 构建环境变量设置命令
539            # 安全:对 value 做 shlex.quote 转义,防止包含 shell 元字符
540            # (如 ;、$()、反引号)的值触发命令注入或带空格的值静默失败
541            env_str = ""
542            if environment:
543                env_vars = [f"export {k}={shlex.quote(str(v))}" for k, v in environment.items()]
544                env_str = "; ".join(env_vars) + "; "
545
546            # 组合完整命令(切换到用户主目录执行)
547            full_command = f"{env_str}cd ~ && {command}"
548
549            # 执行命令(timeout 为 wall-clock 语义,由 _read_output 实施:
550            # 并发排空两流防大输出死锁,超时关闭通道终止远端命令)
551            stdin, stdout, stderr = self._client.exec_command(full_command)
552
553            # 获取命令执行结果(先排空输出流,再取退出状态)
554            exit_code, stdout_data, stderr_data = self._read_output(stdout, stderr, timeout)
555
556            # 构建结果对象
557            result = CommandResult(
558                command=command,
559                stdout=stdout_data,
560                stderr=stderr_data,
561                exit_code=exit_code,
562            )
563
564            logger.debug(f"command finished, exit code: {exit_code}")
565            return result
566
567        except (paramiko.SSHException, OSError) as e:
568            raise SSHCommandError(f"command execution failed: {e}") from e

在远程服务器上执行命令

Args: command: 要执行的命令字符串 timeout: 命令执行 wall-clock 超时时间(秒),None 表示不限时。 超时后关闭通道终止远端命令并抛出 SSHCommandTimeoutError。 输出流在内部并发排空,大输出(超过 SSH 通道窗口)不会死锁 environment: 环境变量字典,将在命令执行前设置

Returns: CommandResult: 包含命令执行结果的对象

Raises: SSHCommandError: 命令执行失败时抛出 SSHConnectionError: 未连接时抛出

Example:

result = client.execute("ls -la") if result.success: ... print(result.stdout)

def execute_sudo( self, command: str, password: str | None = None, timeout: int | None = None) -> remote_cmd.core.ssh_client.CommandResult:
570    def execute_sudo(
571        self,
572        command: str,
573        password: Optional[str] = None,
574        timeout: Optional[int] = None,
575    ) -> CommandResult:
576        """
577        以 sudo 权限执行命令(安全实现)
578
579        Args:
580            command: 要执行的命令字符串(不需要包含 sudo 前缀)
581            password: sudo 密码(如果需要),None 表示使用无密码 sudo
582            timeout: 命令执行超时时间(秒)
583
584        Returns:
585            CommandResult: 包含命令执行结果的对象
586
587        Note:
588            - 如果提供了 password,使用 exec_command + -S 从 stdin 传入密码
589            - 密码不会出现在进程列表或日志中
590            - stdout 和 stderr 保持独立分离
591
592        Example:
593            >>> result = client.execute_sudo("systemctl restart nginx", password="mypass")
594        """
595        if not self._client:
596            raise SSHConnectionError("not connected, call connect() first")
597
598        if password is None:
599            full_command = f"sudo {command}"
600            return self.execute(full_command, timeout)
601
602        # 使用 exec_command + sudo -S 从 stdin 传入密码,保持 stdout/stderr 分离
603        try:
604            full_command = f"sudo -S {command}"
605            # get_pty=False:避免 PTY 合并 stdout/stderr(与文档"独立分离"一致),
606            # 同时关闭 PTY echo 防止 sudo 密码被回显到 stdout 造成凭据泄露
607            stdin, stdout, stderr = self._client.exec_command(full_command, get_pty=False)
608            stdin.write(password + "\n")
609            stdin.flush()
610
611            # 与 execute 一致:先并发排空两流(防大输出死锁),再取退出状态;
612            # timeout 为 wall-clock 语义
613            exit_code, stdout_data, stderr_data = self._read_output(stdout, stderr, timeout)
614
615            return CommandResult(
616                command=command,
617                stdout=stdout_data,
618                stderr=stderr_data,
619                exit_code=exit_code,
620            )
621        except (paramiko.SSHException, OSError) as e:
622            raise SSHCommandError(f"sudo command execution failed: {e}") from e

以 sudo 权限执行命令(安全实现)

Args: command: 要执行的命令字符串(不需要包含 sudo 前缀) password: sudo 密码(如果需要),None 表示使用无密码 sudo timeout: 命令执行超时时间(秒)

Returns: CommandResult: 包含命令执行结果的对象

Note: - 如果提供了 password,使用 exec_command + -S 从 stdin 传入密码 - 密码不会出现在进程列表或日志中 - stdout 和 stderr 保持独立分离

Example:

result = client.execute_sudo("systemctl restart nginx", password="mypass")

def upload_file(self, local_path: str, remote_path: str) -> None:
628    def upload_file(self, local_path: str, remote_path: str) -> None:
629        """
630        上传本地文件到远程服务器
631
632        Args:
633            local_path: 本地文件路径
634            remote_path: 远程目标路径(绝对路径)
635
636        Raises:
637            SSHFileTransferError: 文件传输失败时抛出
638            SSHConnectionError: 未连接时抛出
639
640        Example:
641            >>> client.upload_file("./script.sh", "/home/user/script.sh")
642        """
643        sftp = self._get_sftp()
644
645        # 验证本地文件存在
646        local_file = Path(local_path)
647        if not local_file.exists():
648            raise SSHFileTransferError(f"Local file not found: {local_path}")
649
650        # 执行上传
651        try:
652            logger.info(f"uploading file: {local_path} -> {remote_path}")
653            sftp.put(str(local_file), remote_path)
654            logger.info("file upload finished")
655        except (paramiko.SSHException, OSError) as e:
656            raise SSHFileTransferError(f"file upload failed: {e}") from e

上传本地文件到远程服务器

Args: local_path: 本地文件路径 remote_path: 远程目标路径(绝对路径)

Raises: SSHFileTransferError: 文件传输失败时抛出 SSHConnectionError: 未连接时抛出

Example:

client.upload_file("./script.sh", "/home/user/script.sh")

def download_file(self, remote_path: str, local_path: str) -> None:
658    def download_file(self, remote_path: str, local_path: str) -> None:
659        """
660        从远程服务器下载文件到本地
661
662        Args:
663            remote_path: 远程文件路径(绝对路径)
664            local_path: 本地目标路径
665
666        Raises:
667            SSHFileTransferError: 文件传输失败时抛出
668            SSHConnectionError: 未连接时抛出
669
670        Note:
671            如果本地目录不存在,将自动创建
672
673        Example:
674            >>> client.download_file("/var/log/syslog", "./logs/syslog")
675        """
676        sftp = self._get_sftp()
677
678        # 确保本地目录存在
679        local_file = Path(local_path)
680        local_file.parent.mkdir(parents=True, exist_ok=True)
681
682        # 执行下载
683        try:
684            logger.info(f"downloading file: {remote_path} -> {local_path}")
685            sftp.get(remote_path, str(local_file))
686            logger.info("file download finished")
687        except (paramiko.SSHException, OSError) as e:
688            raise SSHFileTransferError(f"file download failed: {e}") from e

从远程服务器下载文件到本地

Args: remote_path: 远程文件路径(绝对路径) local_path: 本地目标路径

Raises: SSHFileTransferError: 文件传输失败时抛出 SSHConnectionError: 未连接时抛出

Note: 如果本地目录不存在,将自动创建

Example:

client.download_file("/var/log/syslog", "./logs/syslog")

def list_remote_directory( self, remote_path: str = '.') -> list[remote_cmd.core.ssh_client.RemoteFileEntry]:
690    def list_remote_directory(self, remote_path: str = ".") -> list[RemoteFileEntry]:
691        """
692        列出远程目录内容
693
694        Args:
695            remote_path: 远程目录路径,默认为当前目录
696
697        Returns:
698            List[RemoteFileEntry]: 目录项信息列表
699
700        Raises:
701            SSHFileTransferError: 列出目录失败时抛出
702            SSHConnectionError: 未连接时抛出
703
704        Example:
705            >>> entries = client.list_remote_directory("/home/user")
706            >>> for entry in entries:
707            ...     print(f"{entry.name}: {entry.size} bytes")
708        """
709        sftp = self._get_sftp()
710
711        try:
712            entries: list[RemoteFileEntry] = []
713            for entry in sftp.listdir_attr(remote_path):
714                mode = entry.st_mode if entry.st_mode is not None else 0
715                entries.append(
716                    RemoteFileEntry(
717                        name=entry.filename,
718                        size=entry.st_size,
719                        mode=oct(mode)[-3:] if mode else "000",
720                        mtime=entry.st_mtime,
721                        is_dir=bool(mode & stat.S_IFDIR) if mode else False,
722                    )
723                )
724            return entries
725        except (paramiko.SSHException, OSError) as e:
726            raise SSHFileTransferError(f"failed to list remote directory: {e}") from e

列出远程目录内容

Args: remote_path: 远程目录路径,默认为当前目录

Returns: List[RemoteFileEntry]: 目录项信息列表

Raises: SSHFileTransferError: 列出目录失败时抛出 SSHConnectionError: 未连接时抛出

Example:

entries = client.list_remote_directory("/home/user") for entry in entries: ... print(f"{entry.name}: {entry.size} bytes")

def create_remote_directory(self, path: str) -> None:
728    def create_remote_directory(self, path: str) -> None:
729        """创建远程目录(支持递归创建)"""
730        sftp = self._get_sftp()
731
732        def _makedirs(sftp_client: paramiko.SFTPClient, remote_path: str) -> None:
733            if remote_path == "/":
734                return
735            try:
736                sftp_client.stat(remote_path)
737            except OSError:
738                _makedirs(sftp_client, str(Path(remote_path).parent))
739                sftp_client.mkdir(remote_path)
740
741        try:
742            _makedirs(sftp, path)
743            logger.info(f"created remote directory: {path}")
744        except (paramiko.SSHException, OSError) as e:
745            raise SSHFileTransferError(f"failed to create remote directory: {e}") from e

创建远程目录(支持递归创建)

def remove_remote_file(self, path: str) -> None:
747    def remove_remote_file(self, path: str) -> None:
748        """删除远程文件"""
749        sftp = self._get_sftp()
750        try:
751            sftp.remove(path)
752            logger.info(f"deleted remote file: {path}")
753        except (paramiko.SSHException, OSError) as e:
754            raise SSHFileTransferError(f"failed to delete remote file: {e}") from e

删除远程文件

def remove_remote_directory(self, path: str, recursive: bool = False) -> None:
756    def remove_remote_directory(self, path: str, recursive: bool = False) -> None:
757        """删除远程目录"""
758        sftp = self._get_sftp()
759
760        def _rm_recursive(sftp_client: paramiko.SFTPClient, remote_path: str) -> None:
761            """递归删除目录内容,先收集后删除以避免不一致状态"""
762            entries: list[tuple[str, bool]] = []
763            try:
764                for entry in sftp_client.listdir_attr(remote_path):
765                    entries.append((entry.filename, bool(entry.st_mode & stat.S_IFDIR)))
766            except OSError:
767                return
768            # 先删除文件,再递归删除子目录
769            for name, is_dir in entries:
770                full_path = f"{remote_path}/{name}"
771                if is_dir:
772                    _rm_recursive(sftp_client, full_path)
773                else:
774                    sftp_client.remove(full_path)
775            sftp_client.rmdir(remote_path)
776
777        try:
778            if recursive:
779                _rm_recursive(sftp, path)
780            else:
781                sftp.rmdir(path)
782            logger.info(f"deleted remote directory: {path}")
783        except (paramiko.SSHException, OSError) as e:
784            raise SSHFileTransferError(f"failed to delete remote directory: {e}") from e

删除远程目录

def remote_file_exists(self, path: str) -> bool:
786    def remote_file_exists(self, path: str) -> bool:
787        """检查远程文件是否存在"""
788        try:
789            sftp = self._get_sftp()
790            sftp.stat(path)
791            return True
792        except OSError:
793            return False
794        except SSHConnectionError:
795            return False

检查远程文件是否存在

def get_remote_file_info(self, path: str) -> dict[str, typing.Any]:
797    def get_remote_file_info(self, path: str) -> dict[str, Any]:
798        """获取远程文件信息"""
799        sftp = self._get_sftp()
800        try:
801            stat_result = sftp.stat(path)
802            mode = stat_result.st_mode
803            return {
804                "name": Path(path).name,
805                "size": stat_result.st_size,
806                "mode": oct(mode)[-3:] if mode else "000",
807                "mtime": stat_result.st_mtime,
808                "is_dir": stat.S_ISDIR(mode),
809                "is_file": stat.S_ISREG(mode),
810            }
811        except (paramiko.SSHException, OSError) as e:
812            raise SSHFileTransferError(f"failed to get file info: {e}") from e

获取远程文件信息

class AsyncSSHClient:
 43class AsyncSSHClient:
 44    """基于 asyncssh 的原生异步 SSH 客户端。
 45
 46    对外接口与同步 `SSHClient` 一致,是项目中唯一的异步 SSH 客户端实现。
 47
 48    Args:
 49        config: SSH 连接配置
 50        loop: 可选事件循环(已忽略;asyncssh 自行从当前事件循环获取,保留参数仅为
 51            向后兼容)
 52
 53    Note:
 54        本类不持有任何线程池,真正在事件循环上完成 I/O,可与其他 asyncssh 连接
 55        并发复用同一事件循环。
 56    """
 57
 58    def __init__(
 59        self,
 60        config: ConnectionConfig,
 61        loop: Optional[Any] = None,
 62    ) -> None:
 63        self.config = config
 64        self._conn: Optional[asyncssh.SSHClientConnection] = None
 65        self._sftp: Optional[asyncssh.SFTPClient] = None
 66        # 保留 loop 入参仅为向后兼容,asyncssh 自行从当前 event loop 取用
 67        self._loop = loop
 68
 69    # ------------------------------------------------------------------
 70    # 连接管理
 71    # ------------------------------------------------------------------
 72    async def connect(self) -> "AsyncSSHClient":
 73        """异步建立 SSH 连接。
 74
 75        Returns:
 76            AsyncSSHClient: 已连接的客户端实例(支持链式调用)
 77
 78        Raises:
 79            SSHConnectionError: 连接/认证失败时抛出,包含原因映射
 80        """
 81        if self.is_connected():
 82            return self
 83
 84        connect_kwargs: dict[str, Any] = {
 85            "host": self.config.hostname,
 86            "port": self.config.port,
 87            "username": self.config.username,
 88            "known_hosts": self._build_known_hosts(),
 89            "login_timeout": self.config.timeout,
 90        }
 91
 92        # 认证方式:密码优先,其次密钥,最后交给 asyncssh 默认(含 agent)
 93        if self.config.password:
 94            connect_kwargs["password"] = self.config.password
 95        elif self.config.key_filename:
 96            key_path = Path(self.config.key_filename).expanduser()
 97            if not key_path.exists():
 98                raise SSHConnectionError(f"SSH key file not found: {key_path}")
 99            connect_kwargs["client_keys"] = [str(key_path)]
100
101        logger.info(f"connecting to {self.config.hostname}:{self.config.port}")
102        try:
103            self._conn = await asyncssh.connect(**connect_kwargs)
104        except asyncssh.PermissionDenied as e:
105            # 永久性错误:重试同一凭据只会加剧账号锁定(见 service/retry_policy.py)
106            raise SSHAuthenticationError(f"authentication failed: {e}") from e
107        except (OSError, asyncssh.Error) as e:
108            msg = str(e).lower()
109            if "timed out" in msg or "timeout" in msg or isinstance(e, asyncssh.TimeoutError):
110                raise SSHTimeoutError(f"connection timeout: {self.config.hostname}") from e
111            raise SSHConnectionError(f"connection error: {e}") from e
112
113        logger.info(f"connected to {self.config.hostname}")
114        return self
115
116    def _build_known_hosts(self) -> Any:
117        """根据 ConnectionConfig 构建 asyncssh known_hosts 配置。
118
119        策略对齐同步 SSHClient:
120        - 若显式提供 known_hosts_file,使用该文件做严格校验
121        - 若配置传入 paramiko.AutoAddPolicy 等价信号(通过 host_key_policy 字符串
122          'auto' 或 False 判定),自动接受新主机密钥(仅用于测试/受控环境)
123        - 默认使用 asyncssh 默认策略(~/.ssh/known_hosts)
124        """
125        if self.config.known_hosts_file:
126            path = Path(self.config.known_hosts_file).expanduser()
127            return str(path)
128        policy = self.config.host_key_policy
129        # 约定:传入字符串 "auto" 视为自动添加(受控场景)
130        if isinstance(policy, str) and policy.lower() == "auto":
131            # 安全:asyncssh 中 known_hosts=None 会完全跳过主机密钥校验
132            # (比 paramiko AutoAddPolicy 更危险,连密钥都不落盘)。
133            # asyncssh 不提供等价的 AutoAddPolicy,此处回退到默认 known_hosts
134            # 校验并发出警告,避免静默禁用所有 MITM 防护。
135            logger.warning(
136                "SECURITY WARNING: 'auto' host key policy requested for asyncssh, "
137                "but asyncssh has no AutoAddPolicy equivalent. Falling back to "
138                "default known_hosts verification (~/.ssh/known_hosts). "
139                "Pre-load host keys or set known_hosts_file to trust specific hosts."
140            )
141            return ()
142        # 默认交由 asyncssh 处理用户 ~/.ssh/known_hosts
143        return ()
144
145    async def disconnect(self) -> None:
146        """异步断开 SSH 连接并清理 SFTP 资源。即使连接已断开也能安全调用。"""
147        if self._sftp is not None:
148            try:
149                # asyncssh SFTPClient.exit() 是同步方法,仅关闭通道资源
150                self._sftp.exit()
151            except (OSError, asyncssh.Error) as e:
152                logger.warning(f"error closing SFTP connection: {e}")
153            finally:
154                self._sftp = None
155
156        if self._conn is not None:
157            try:
158                self._conn.close()
159                # await close 完成底层通道清理,但忽略可能抛出的 ConnectionLost / asyncssh.Error
160                await self._conn.wait_closed()
161            except (OSError, asyncssh.Error) as e:
162                logger.warning(f"error closing SSH connection: {e}")
163            finally:
164                self._conn = None
165
166    def is_connected(self) -> bool:
167        """检查连接是否处于活动状态。"""
168        return self._conn is not None and not self._conn.is_closed()
169
170    async def _get_conn(self) -> asyncssh.SSHClientConnection:
171        if self._conn is None:
172            raise SSHConnectionError("not connected, call connect() first")
173        return self._conn
174
175    # ------------------------------------------------------------------
176    # 命令执行
177    # ------------------------------------------------------------------
178    async def execute(
179        self,
180        command: str,
181        timeout: Optional[int] = None,
182        environment: Optional[dict[str, str]] = None,
183    ) -> CommandResult:
184        """异步执行远程命令。
185
186        Args:
187            command: 要执行的命令字符串
188            timeout: 命令执行超时(秒),None 表示不限
189            environment: 命令执行前注入的环境变量
190
191        Returns:
192            CommandResult: 命令结果(与同步实现字段一致)
193
194        Raises:
195            SSHCommandError: 命令执行失败时抛出
196            SSHConnectionError: 未连接时抛出
197        """
198        conn = await self._get_conn()
199        # 安全:键必须为合法 shell 标识符(与同步实现一致,防止命令注入)
200        validate_environment(environment)
201        # 安全:对 value 做 shlex.quote 转义,防止 shell 元字符注入
202        env_str = ""
203        if environment:
204            env_str = (
205                "; ".join(f"export {k}={shlex.quote(str(v))}" for k, v in environment.items())
206                + "; "
207            )
208        full_command = f"{env_str}cd ~ && {command}"
209        # 安全:不记录命令全文(可能含敏感参数),仅记录执行事件
210        logger.debug("executing remote command")
211        try:
212            # 环境变量仅通过命令前缀的 export 注入(与同步 SSHClient 行为
213            # 一致):conn.run(env=...) 依赖服务端 AcceptEnv 且语义分叉,
214            # 不再重复传递
215            result = await conn.run(
216                full_command,
217                timeout=timeout,
218                check=False,
219            )
220        except (OSError, asyncssh.Error) as e:
221            raise SSHCommandError(f"command execution failed: {e}") from e
222
223        stdout_data = (
224            result.stdout
225            if isinstance(result.stdout, str)
226            else (result.stdout.decode("utf-8", errors="replace") if result.stdout else "")
227        )
228        stderr_data = (
229            result.stderr
230            if isinstance(result.stderr, str)
231            else (result.stderr.decode("utf-8", errors="replace") if result.stderr else "")
232        )
233        exit_code = int(result.exit_status) if result.exit_status is not None else -1
234
235        return CommandResult(
236            command=command,
237            stdout=stdout_data,
238            stderr=stderr_data,
239            exit_code=exit_code,
240        )
241
242    async def execute_sudo(
243        self,
244        command: str,
245        password: Optional[str] = None,
246        timeout: Optional[int] = None,
247    ) -> CommandResult:
248        """以 sudo 权限异步执行命令(安全实现:密码通过 stdin 传入,不进入进程列表)。"""
249        conn = await self._get_conn()
250        if password is None:
251            return await self.execute(f"sudo {command}", timeout=timeout)
252
253        try:
254            proc: asyncssh.SSHClientProcess = await conn.create_process(
255                f"sudo -S {command}",
256                timeout=timeout,
257            )
258        except (OSError, asyncssh.Error) as e:
259            raise SSHCommandError(f"sudo command execution failed: {e}") from e
260
261        try:
262            proc.stdin.write(password + "\n")
263            proc.stdin.write_eof()
264            # 与 execute 的 conn.run(timeout=...) 语义对齐:timeout 覆盖整个命令执行
265            # wall-clock,避免挂起的 sudo(如等待密码)无限等待
266            result = await proc.wait(timeout=timeout)
267        except (OSError, asyncssh.Error) as e:
268            raise SSHCommandError(f"sudo command execution failed: {e}") from e
269
270        stdout_data = (
271            result.stdout
272            if isinstance(result.stdout, str)
273            else (result.stdout.decode("utf-8", errors="replace") if result.stdout else "")
274        )
275        stderr_data = (
276            result.stderr
277            if isinstance(result.stderr, str)
278            else (result.stderr.decode("utf-8", errors="replace") if result.stderr else "")
279        )
280        return CommandResult(
281            command=command,
282            stdout=stdout_data,
283            stderr=stderr_data,
284            exit_code=int(result.exit_status) if result.exit_status is not None else -1,
285        )
286
287    # ------------------------------------------------------------------
288    # SFTP / 文件传输
289    # ------------------------------------------------------------------
290    async def _get_sftp(self) -> asyncssh.SFTPClient:
291        conn = await self._get_conn()
292        if self._sftp is None:
293            try:
294                self._sftp = await conn.start_sftp_client()
295            except (OSError, asyncssh.Error) as e:
296                raise SSHFileTransferError(f"failed to open SFTP channel: {e}") from e
297        return self._sftp
298
299    async def upload_file(self, local_path: str, remote_path: str) -> None:
300        """异步上传本地文件到远程服务器。"""
301        sftp = await self._get_sftp()
302        local_file = Path(local_path)
303        if not local_file.exists():
304            raise SSHFileTransferError(f"Local file not found: {local_path}")
305        logger.info(f"uploading file: {local_path} -> {remote_path}")
306        try:
307            await sftp.put(str(local_file), remote_path)
308        except (OSError, asyncssh.Error) as e:
309            raise SSHFileTransferError(f"file upload failed: {e}") from e
310        logger.info("file upload finished")
311
312    async def download_file(self, remote_path: str, local_path: str) -> None:
313        """异步从远程服务器下载文件到本地。"""
314        sftp = await self._get_sftp()
315        local_file = Path(local_path)
316        local_file.parent.mkdir(parents=True, exist_ok=True)
317        logger.info(f"downloading file: {remote_path} -> {local_path}")
318        try:
319            await sftp.get(remote_path, str(local_file))
320        except (OSError, asyncssh.Error) as e:
321            raise SSHFileTransferError(f"file download failed: {e}") from e
322        logger.info("file download finished")
323
324    async def list_remote_directory(self, remote_path: str = ".") -> list[RemoteFileEntry]:
325        """异步列出远程目录内容(结构与同步 SSHClient 一致)。"""
326        sftp = await self._get_sftp()
327        try:
328            names = await sftp.readdir(remote_path)
329        except (OSError, asyncssh.Error) as e:
330            raise SSHFileTransferError(f"failed to list remote directory: {e}") from e
331
332        entries: list[RemoteFileEntry] = []
333        for entry in names:
334            attrs = entry.attrs
335            mode = attrs.permissions if hasattr(attrs, "permissions") else None
336            raw_size = attrs.size if hasattr(attrs, "size") else None
337            raw_mtime = attrs.mtime if hasattr(attrs, "mtime") else 0
338            entries.append(
339                RemoteFileEntry(
340                    name=str(entry.filename),
341                    size=raw_size if raw_size is not None else 0,
342                    mode=oct(int(mode))[-3:] if mode else "000",
343                    mtime=raw_mtime,
344                    is_dir=bool(mode & stat.S_IFDIR) if mode else False,
345                )
346            )
347        return entries
348
349    # ------------------------------------------------------------------
350    # 上下文管理器
351    # ------------------------------------------------------------------
352    async def __aenter__(self) -> "AsyncSSHClient":
353        await self.connect()
354        return self
355
356    async def __aexit__(self, exc_type, exc, tb) -> None:
357        await self.disconnect()

基于 asyncssh 的原生异步 SSH 客户端。

对外接口与同步 SSHClient 一致,是项目中唯一的异步 SSH 客户端实现。

Args: config: SSH 连接配置 loop: 可选事件循环(已忽略;asyncssh 自行从当前事件循环获取,保留参数仅为 向后兼容)

Note: 本类不持有任何线程池,真正在事件循环上完成 I/O,可与其他 asyncssh 连接 并发复用同一事件循环。

AsyncSSHClient( config: remote_cmd.core.ssh_client.ConnectionConfig, loop: Any | None = None)
58    def __init__(
59        self,
60        config: ConnectionConfig,
61        loop: Optional[Any] = None,
62    ) -> None:
63        self.config = config
64        self._conn: Optional[asyncssh.SSHClientConnection] = None
65        self._sftp: Optional[asyncssh.SFTPClient] = None
66        # 保留 loop 入参仅为向后兼容,asyncssh 自行从当前 event loop 取用
67        self._loop = loop
config
async def connect(self) -> 'AsyncSSHClient':
 72    async def connect(self) -> "AsyncSSHClient":
 73        """异步建立 SSH 连接。
 74
 75        Returns:
 76            AsyncSSHClient: 已连接的客户端实例(支持链式调用)
 77
 78        Raises:
 79            SSHConnectionError: 连接/认证失败时抛出,包含原因映射
 80        """
 81        if self.is_connected():
 82            return self
 83
 84        connect_kwargs: dict[str, Any] = {
 85            "host": self.config.hostname,
 86            "port": self.config.port,
 87            "username": self.config.username,
 88            "known_hosts": self._build_known_hosts(),
 89            "login_timeout": self.config.timeout,
 90        }
 91
 92        # 认证方式:密码优先,其次密钥,最后交给 asyncssh 默认(含 agent)
 93        if self.config.password:
 94            connect_kwargs["password"] = self.config.password
 95        elif self.config.key_filename:
 96            key_path = Path(self.config.key_filename).expanduser()
 97            if not key_path.exists():
 98                raise SSHConnectionError(f"SSH key file not found: {key_path}")
 99            connect_kwargs["client_keys"] = [str(key_path)]
100
101        logger.info(f"connecting to {self.config.hostname}:{self.config.port}")
102        try:
103            self._conn = await asyncssh.connect(**connect_kwargs)
104        except asyncssh.PermissionDenied as e:
105            # 永久性错误:重试同一凭据只会加剧账号锁定(见 service/retry_policy.py)
106            raise SSHAuthenticationError(f"authentication failed: {e}") from e
107        except (OSError, asyncssh.Error) as e:
108            msg = str(e).lower()
109            if "timed out" in msg or "timeout" in msg or isinstance(e, asyncssh.TimeoutError):
110                raise SSHTimeoutError(f"connection timeout: {self.config.hostname}") from e
111            raise SSHConnectionError(f"connection error: {e}") from e
112
113        logger.info(f"connected to {self.config.hostname}")
114        return self

异步建立 SSH 连接。

Returns: AsyncSSHClient: 已连接的客户端实例(支持链式调用)

Raises: SSHConnectionError: 连接/认证失败时抛出,包含原因映射

async def disconnect(self) -> None:
145    async def disconnect(self) -> None:
146        """异步断开 SSH 连接并清理 SFTP 资源。即使连接已断开也能安全调用。"""
147        if self._sftp is not None:
148            try:
149                # asyncssh SFTPClient.exit() 是同步方法,仅关闭通道资源
150                self._sftp.exit()
151            except (OSError, asyncssh.Error) as e:
152                logger.warning(f"error closing SFTP connection: {e}")
153            finally:
154                self._sftp = None
155
156        if self._conn is not None:
157            try:
158                self._conn.close()
159                # await close 完成底层通道清理,但忽略可能抛出的 ConnectionLost / asyncssh.Error
160                await self._conn.wait_closed()
161            except (OSError, asyncssh.Error) as e:
162                logger.warning(f"error closing SSH connection: {e}")
163            finally:
164                self._conn = None

异步断开 SSH 连接并清理 SFTP 资源。即使连接已断开也能安全调用。

def is_connected(self) -> bool:
166    def is_connected(self) -> bool:
167        """检查连接是否处于活动状态。"""
168        return self._conn is not None and not self._conn.is_closed()

检查连接是否处于活动状态。

async def execute( self, command: str, timeout: int | None = None, environment: dict[str, str] | None = None) -> remote_cmd.core.ssh_client.CommandResult:
178    async def execute(
179        self,
180        command: str,
181        timeout: Optional[int] = None,
182        environment: Optional[dict[str, str]] = None,
183    ) -> CommandResult:
184        """异步执行远程命令。
185
186        Args:
187            command: 要执行的命令字符串
188            timeout: 命令执行超时(秒),None 表示不限
189            environment: 命令执行前注入的环境变量
190
191        Returns:
192            CommandResult: 命令结果(与同步实现字段一致)
193
194        Raises:
195            SSHCommandError: 命令执行失败时抛出
196            SSHConnectionError: 未连接时抛出
197        """
198        conn = await self._get_conn()
199        # 安全:键必须为合法 shell 标识符(与同步实现一致,防止命令注入)
200        validate_environment(environment)
201        # 安全:对 value 做 shlex.quote 转义,防止 shell 元字符注入
202        env_str = ""
203        if environment:
204            env_str = (
205                "; ".join(f"export {k}={shlex.quote(str(v))}" for k, v in environment.items())
206                + "; "
207            )
208        full_command = f"{env_str}cd ~ && {command}"
209        # 安全:不记录命令全文(可能含敏感参数),仅记录执行事件
210        logger.debug("executing remote command")
211        try:
212            # 环境变量仅通过命令前缀的 export 注入(与同步 SSHClient 行为
213            # 一致):conn.run(env=...) 依赖服务端 AcceptEnv 且语义分叉,
214            # 不再重复传递
215            result = await conn.run(
216                full_command,
217                timeout=timeout,
218                check=False,
219            )
220        except (OSError, asyncssh.Error) as e:
221            raise SSHCommandError(f"command execution failed: {e}") from e
222
223        stdout_data = (
224            result.stdout
225            if isinstance(result.stdout, str)
226            else (result.stdout.decode("utf-8", errors="replace") if result.stdout else "")
227        )
228        stderr_data = (
229            result.stderr
230            if isinstance(result.stderr, str)
231            else (result.stderr.decode("utf-8", errors="replace") if result.stderr else "")
232        )
233        exit_code = int(result.exit_status) if result.exit_status is not None else -1
234
235        return CommandResult(
236            command=command,
237            stdout=stdout_data,
238            stderr=stderr_data,
239            exit_code=exit_code,
240        )

异步执行远程命令。

Args: command: 要执行的命令字符串 timeout: 命令执行超时(秒),None 表示不限 environment: 命令执行前注入的环境变量

Returns: CommandResult: 命令结果(与同步实现字段一致)

Raises: SSHCommandError: 命令执行失败时抛出 SSHConnectionError: 未连接时抛出

async def execute_sudo( self, command: str, password: str | None = None, timeout: int | None = None) -> remote_cmd.core.ssh_client.CommandResult:
242    async def execute_sudo(
243        self,
244        command: str,
245        password: Optional[str] = None,
246        timeout: Optional[int] = None,
247    ) -> CommandResult:
248        """以 sudo 权限异步执行命令(安全实现:密码通过 stdin 传入,不进入进程列表)。"""
249        conn = await self._get_conn()
250        if password is None:
251            return await self.execute(f"sudo {command}", timeout=timeout)
252
253        try:
254            proc: asyncssh.SSHClientProcess = await conn.create_process(
255                f"sudo -S {command}",
256                timeout=timeout,
257            )
258        except (OSError, asyncssh.Error) as e:
259            raise SSHCommandError(f"sudo command execution failed: {e}") from e
260
261        try:
262            proc.stdin.write(password + "\n")
263            proc.stdin.write_eof()
264            # 与 execute 的 conn.run(timeout=...) 语义对齐:timeout 覆盖整个命令执行
265            # wall-clock,避免挂起的 sudo(如等待密码)无限等待
266            result = await proc.wait(timeout=timeout)
267        except (OSError, asyncssh.Error) as e:
268            raise SSHCommandError(f"sudo command execution failed: {e}") from e
269
270        stdout_data = (
271            result.stdout
272            if isinstance(result.stdout, str)
273            else (result.stdout.decode("utf-8", errors="replace") if result.stdout else "")
274        )
275        stderr_data = (
276            result.stderr
277            if isinstance(result.stderr, str)
278            else (result.stderr.decode("utf-8", errors="replace") if result.stderr else "")
279        )
280        return CommandResult(
281            command=command,
282            stdout=stdout_data,
283            stderr=stderr_data,
284            exit_code=int(result.exit_status) if result.exit_status is not None else -1,
285        )

以 sudo 权限异步执行命令(安全实现:密码通过 stdin 传入,不进入进程列表)。

async def upload_file(self, local_path: str, remote_path: str) -> None:
299    async def upload_file(self, local_path: str, remote_path: str) -> None:
300        """异步上传本地文件到远程服务器。"""
301        sftp = await self._get_sftp()
302        local_file = Path(local_path)
303        if not local_file.exists():
304            raise SSHFileTransferError(f"Local file not found: {local_path}")
305        logger.info(f"uploading file: {local_path} -> {remote_path}")
306        try:
307            await sftp.put(str(local_file), remote_path)
308        except (OSError, asyncssh.Error) as e:
309            raise SSHFileTransferError(f"file upload failed: {e}") from e
310        logger.info("file upload finished")

异步上传本地文件到远程服务器。

async def download_file(self, remote_path: str, local_path: str) -> None:
312    async def download_file(self, remote_path: str, local_path: str) -> None:
313        """异步从远程服务器下载文件到本地。"""
314        sftp = await self._get_sftp()
315        local_file = Path(local_path)
316        local_file.parent.mkdir(parents=True, exist_ok=True)
317        logger.info(f"downloading file: {remote_path} -> {local_path}")
318        try:
319            await sftp.get(remote_path, str(local_file))
320        except (OSError, asyncssh.Error) as e:
321            raise SSHFileTransferError(f"file download failed: {e}") from e
322        logger.info("file download finished")

异步从远程服务器下载文件到本地。

async def list_remote_directory( self, remote_path: str = '.') -> list[remote_cmd.core.ssh_client.RemoteFileEntry]:
324    async def list_remote_directory(self, remote_path: str = ".") -> list[RemoteFileEntry]:
325        """异步列出远程目录内容(结构与同步 SSHClient 一致)。"""
326        sftp = await self._get_sftp()
327        try:
328            names = await sftp.readdir(remote_path)
329        except (OSError, asyncssh.Error) as e:
330            raise SSHFileTransferError(f"failed to list remote directory: {e}") from e
331
332        entries: list[RemoteFileEntry] = []
333        for entry in names:
334            attrs = entry.attrs
335            mode = attrs.permissions if hasattr(attrs, "permissions") else None
336            raw_size = attrs.size if hasattr(attrs, "size") else None
337            raw_mtime = attrs.mtime if hasattr(attrs, "mtime") else 0
338            entries.append(
339                RemoteFileEntry(
340                    name=str(entry.filename),
341                    size=raw_size if raw_size is not None else 0,
342                    mode=oct(int(mode))[-3:] if mode else "000",
343                    mtime=raw_mtime,
344                    is_dir=bool(mode & stat.S_IFDIR) if mode else False,
345                )
346            )
347        return entries

异步列出远程目录内容(结构与同步 SSHClient 一致)。

ConnectionPool = <class 'AsyncConnectionPool'>
NativeAsyncSSHClient = <class 'AsyncSSHClient'>
class AsyncConnectionPool:
 34class AsyncConnectionPool:
 35    """原生异步 SSH 连接池。
 36
 37    Args:
 38        config: 用于建立 SSH 连接的配置
 39        max_connections: 同一最大连接数(同一配置可复用)
 40        max_lifetime: 连接最大生命周期(秒),超过自动关闭
 41        idle_timeout: 空闲超时(秒),超过自动关闭
 42        health_check_interval: 后台清理任务周期(秒)
 43    """
 44
 45    def __init__(
 46        self,
 47        config: ConnectionConfig,
 48        max_connections: int = 10,
 49        max_lifetime: int = 3600,
 50        idle_timeout: int = 300,
 51        health_check_interval: int = 60,
 52        client_factory: Optional[Any] = None,
 53    ) -> None:
 54        """
 55        Args:
 56            config: 用于建立 SSH 连接的配置
 57            max_connections: 同一最大连接数(同一配置可复用)
 58            max_lifetime: 连接最大生命周期(秒),超过自动关闭
 59            idle_timeout: 空闲超时(秒),超过自动关闭
 60            health_check_interval: 后台清理任务周期(秒)
 61            client_factory: 客户端工厂,默认为 AsyncSSHClient;测试可注入
 62                mock(与 SyncConnectionPool 对齐)
 63        """
 64        self.config = config
 65        self._max = max_connections
 66        self._max_lifetime = max_lifetime
 67        self._idle_timeout = idle_timeout
 68        self._health_check_interval = health_check_interval
 69        # 客户端工厂:默认为 AsyncSSHClient;测试可注入 mock
 70        self._client_factory = client_factory or AsyncSSHClient
 71
 72        # 容器
 73        self._connections: list[AsyncSSHClient] = []
 74        self._free: asyncio.Queue[AsyncSSHClient] = asyncio.Queue()
 75        self._semaphore = asyncio.Semaphore(max_connections)
 76        self._lock = asyncio.Lock()
 77
 78        # 生命周期状态:close_all() 后置 True,禁止再借用/归还
 79        self._closed = False
 80
 81        # 指标
 82        self._total_created = 0
 83        self._total_reconnects = 0
 84        self._total_failed = 0
 85        self._total_released = 0
 86
 87        # 后台清理任务
 88        self._monitor_task: Optional[asyncio.Task[None]] = None
 89
 90        # 连接元数据(副表,避免侵入 AsyncSSHClient 私有属性)
 91        self._meta: dict[int, ConnectionMeta] = {}
 92
 93    # ------------------------------------------------------------------
 94    # 指标
 95    # ------------------------------------------------------------------
 96    def get_metrics(self) -> dict[str, Any]:
 97        """获取连接池指标快照。"""
 98        return {
 99            # 当前在用的连接数 = 存活连接总数 - 空闲连接数。
100            # 不能用 total_created - total_released:复用连接时
101            # total_released 会超过 total_created,导致 active 为负。
102            "active": len(self._connections) - self._free.qsize(),
103            "idle": self._free.qsize(),
104            "total_connections": len(self._connections),
105            "total_created": self._total_created,
106            "reconnects": self._total_reconnects,
107            "failed": self._total_failed,
108            "max_connections": self._max,
109            "max_lifetime": self._max_lifetime,
110            "idle_timeout": self._idle_timeout,
111        }
112
113    # ------------------------------------------------------------------
114    # 获取 / 释放
115    # ------------------------------------------------------------------
116    async def acquire(self) -> AsyncSSHClient:
117        """从池中获取一个可用连接,必要时创建新连接。
118
119        Returns:
120            AsyncSSHClient: 可用的异步客户端
121
122        Raises:
123            SSHConnectionError: 创建连接失败
124            RuntimeError: 连接池已关闭(close_all 之后)
125        """
126        if self._closed:
127            raise RuntimeError("connection pool is closed")
128        await self._semaphore.acquire()
129        # 竞态守卫:等待信号量期间 close_all() 可能已完成——
130        # 取得槽位后必须复查,已关闭则归还槽位并抛出既有错误,
131        # 否则会向调用方发放来自已关闭池的连接
132        if self._closed:
133            self._semaphore.release()
134            raise RuntimeError("connection pool is closed")
135        try:
136            # 优先复用空闲连接
137            while not self._free.empty():
138                conn = self._free.get_nowait()
139                if await self._check_connection(conn):
140                    self._touch(conn)
141                    return conn
142                await self._close_connection(conn)
143
144            # 创建新连接(信号量已保证未超额)
145            return await self._create_connection()
146        except BaseException:
147            self._semaphore.release()
148            raise
149
150    async def release(self, conn: Optional[AsyncSSHClient]) -> None:
151        """归还连接到池中(如已断开/超时则关闭)。"""
152        if conn is None:
153            return
154        # 池已关闭:不把连接放回空闲队列(避免游离连接),直接关闭并释放槽位
155        if self._closed:
156            await self._close_connection(conn)
157            self._semaphore.release()
158            self._total_released += 1
159            return
160        meta = self._meta.get(id(conn))
161        if meta is not None:
162            meta.last_used = time.time()
163
164        if not conn.is_connected():
165            await self._close_connection(conn)
166            self._semaphore.release()
167            return
168
169        # 生命周期 / 空闲超时则关闭
170        if meta and should_close(meta, self._max_lifetime, self._idle_timeout, True):
171            await self._close_connection(conn)
172            self._semaphore.release()
173            return
174
175        try:
176            async with self._lock:
177                self._free.put_nowait(conn)
178            # 放回 free 后释放许可:free 中的连接不再占用并发槽位,
179            # 后续 acquire 会从 free 直接复用(无需再次获取许可)
180            self._semaphore.release()
181        except asyncio.QueueFull:
182            await self._close_connection(conn)
183            self._semaphore.release()
184        finally:
185            self._total_released += 1
186
187    # ------------------------------------------------------------------
188    # 内部
189    # ------------------------------------------------------------------
190    async def _create_connection(self) -> AsyncSSHClient:
191        client = self._client_factory(self.config)
192        try:
193            await client.connect()
194        except Exception:  # noqa: BLE001
195            # 信号量由 acquire() 的 except 统一释放,此处不再释放
196            self._total_failed += 1
197            raise
198        self._connections.append(client)
199        now = time.time()
200        self._meta[id(client)] = ConnectionMeta(
201            created_at=now,
202            last_used=now,
203            conn_id=uuid.uuid4().hex,
204        )
205        self._total_created += 1
206        return client
207
208    def _touch(self, conn: AsyncSSHClient) -> None:
209        meta = self._meta.get(id(conn))
210        if meta is not None:
211            meta.last_used = time.time()
212
213    async def _check_connection(self, conn: AsyncSSHClient) -> bool:
214        if not conn.is_connected():
215            return False
216        meta = self._meta.get(id(conn))
217        if meta is None:
218            return True
219        if lifetime_expired(meta.created_at, self._max_lifetime):
220            logger.debug("connection %s exceeded max lifetime", meta.conn_id[:8])
221            return False
222        # 连接刚使用过(空闲未超时)则信任其状态,避免频繁探活开销
223        # (与 SyncConnectionPool._check_connection 保持一致)
224        if not idle_expired(meta.last_used, self._idle_timeout):
225            return True
226        # 空闲较久才触发轻量探活:发出一个无害命令
227        try:
228            result = await conn.execute("true", timeout=5)
229            return result.success
230        except Exception as e:  # noqa: BLE001
231            self._total_reconnects += 1
232            logger.debug("connection liveness check failed: %s", e)
233            return False
234
235    async def _close_connection(self, conn: AsyncSSHClient) -> None:
236        with contextlib.suppress(Exception):
237            await conn.disconnect()
238        self._meta.pop(id(conn), None)
239        if conn in self._connections:
240            self._connections.remove(conn)
241
242    # ------------------------------------------------------------------
243    # 后台监控
244    # ------------------------------------------------------------------
245    def _start_monitor(self) -> None:
246        if self._monitor_task is None or self._monitor_task.done():
247            self._monitor_task = asyncio.create_task(self._monitor_loop())
248
249    def stop_monitor(self) -> None:
250        if self._monitor_task and not self._monitor_task.done():
251            self._monitor_task.cancel()
252
253    async def _monitor_loop(self) -> None:
254        while True:
255            try:
256                await asyncio.sleep(self._health_check_interval)
257                await self._cleanup_expired()
258            except asyncio.CancelledError:
259                break
260            except Exception:  # noqa: BLE001
261                logger.warning("connection pool monitor error")
262
263    async def _cleanup_expired(self) -> None:
264        now = time.time()
265        # 在锁保护下排空 _free 快照,绝不替换队列对象。
266        # 旧实现 self._free = kept 会替换队列对象,在 await 让出点 release()
267        # 可能 put 到旧队列导致连接泄漏(asyncio 协程交错使竞态比同步版更易触发)。
268        async with self._lock:
269            snapshot: list[AsyncSSHClient] = []
270            while not self._free.empty():
271                snapshot.append(self._free.get_nowait())
272        keep: list[AsyncSSHClient] = []
273        for conn in snapshot:
274            meta = self._meta.get(id(conn))
275            if should_close(meta, self._max_lifetime, self._idle_timeout, conn.is_connected(), now):
276                await self._close_connection(conn)
277                continue
278            keep.append(conn)
279        # 将存活连接放回同一队列对象
280        async with self._lock:
281            for conn in keep:
282                self._free.put_nowait(conn)
283
284    # ------------------------------------------------------------------
285    # 上下文管理
286    # ------------------------------------------------------------------
287    class _AcquireContext:
288        def __init__(self, pool: "AsyncConnectionPool") -> None:
289            self._pool = pool
290            self._conn: Optional[AsyncSSHClient] = None
291
292        async def __aenter__(self) -> AsyncSSHClient:
293            self._conn = await self._pool.acquire()
294            return self._conn
295
296        async def __aexit__(self, exc_type, exc, tb) -> None:
297            await self._pool.release(self._conn)
298            self._conn = None
299
300    def acquire_context(self) -> "_AcquireContext":
301        """获取连接的上下文管理器。"""
302        return AsyncConnectionPool._AcquireContext(self)
303
304    async def close_all(self) -> None:
305        """关闭池中所有连接并停止监控。"""
306        self._closed = True
307        self.stop_monitor()
308        for conn in list(self._connections):
309            await self._close_connection(conn)
310        # 释放所有信号量
311        while self._free.empty() is False:
312            self._free.get_nowait()
313
314    async def __aenter__(self) -> "AsyncConnectionPool":
315        self._start_monitor()
316        return self
317
318    async def __aexit__(self, exc_type, exc, tb) -> None:
319        await self.close_all()

原生异步 SSH 连接池。

Args: config: 用于建立 SSH 连接的配置 max_connections: 同一最大连接数(同一配置可复用) max_lifetime: 连接最大生命周期(秒),超过自动关闭 idle_timeout: 空闲超时(秒),超过自动关闭 health_check_interval: 后台清理任务周期(秒)

AsyncConnectionPool( config: remote_cmd.core.ssh_client.ConnectionConfig, max_connections: int = 10, max_lifetime: int = 3600, idle_timeout: int = 300, health_check_interval: int = 60, client_factory: Any | None = None)
45    def __init__(
46        self,
47        config: ConnectionConfig,
48        max_connections: int = 10,
49        max_lifetime: int = 3600,
50        idle_timeout: int = 300,
51        health_check_interval: int = 60,
52        client_factory: Optional[Any] = None,
53    ) -> None:
54        """
55        Args:
56            config: 用于建立 SSH 连接的配置
57            max_connections: 同一最大连接数(同一配置可复用)
58            max_lifetime: 连接最大生命周期(秒),超过自动关闭
59            idle_timeout: 空闲超时(秒),超过自动关闭
60            health_check_interval: 后台清理任务周期(秒)
61            client_factory: 客户端工厂,默认为 AsyncSSHClient;测试可注入
62                mock(与 SyncConnectionPool 对齐)
63        """
64        self.config = config
65        self._max = max_connections
66        self._max_lifetime = max_lifetime
67        self._idle_timeout = idle_timeout
68        self._health_check_interval = health_check_interval
69        # 客户端工厂:默认为 AsyncSSHClient;测试可注入 mock
70        self._client_factory = client_factory or AsyncSSHClient
71
72        # 容器
73        self._connections: list[AsyncSSHClient] = []
74        self._free: asyncio.Queue[AsyncSSHClient] = asyncio.Queue()
75        self._semaphore = asyncio.Semaphore(max_connections)
76        self._lock = asyncio.Lock()
77
78        # 生命周期状态:close_all() 后置 True,禁止再借用/归还
79        self._closed = False
80
81        # 指标
82        self._total_created = 0
83        self._total_reconnects = 0
84        self._total_failed = 0
85        self._total_released = 0
86
87        # 后台清理任务
88        self._monitor_task: Optional[asyncio.Task[None]] = None
89
90        # 连接元数据(副表,避免侵入 AsyncSSHClient 私有属性)
91        self._meta: dict[int, ConnectionMeta] = {}

Args: config: 用于建立 SSH 连接的配置 max_connections: 同一最大连接数(同一配置可复用) max_lifetime: 连接最大生命周期(秒),超过自动关闭 idle_timeout: 空闲超时(秒),超过自动关闭 health_check_interval: 后台清理任务周期(秒) client_factory: 客户端工厂,默认为 AsyncSSHClient;测试可注入 mock(与 SyncConnectionPool 对齐)

config
def get_metrics(self) -> dict[str, typing.Any]:
 96    def get_metrics(self) -> dict[str, Any]:
 97        """获取连接池指标快照。"""
 98        return {
 99            # 当前在用的连接数 = 存活连接总数 - 空闲连接数。
100            # 不能用 total_created - total_released:复用连接时
101            # total_released 会超过 total_created,导致 active 为负。
102            "active": len(self._connections) - self._free.qsize(),
103            "idle": self._free.qsize(),
104            "total_connections": len(self._connections),
105            "total_created": self._total_created,
106            "reconnects": self._total_reconnects,
107            "failed": self._total_failed,
108            "max_connections": self._max,
109            "max_lifetime": self._max_lifetime,
110            "idle_timeout": self._idle_timeout,
111        }

获取连接池指标快照。

async def acquire(self) -> AsyncSSHClient:
116    async def acquire(self) -> AsyncSSHClient:
117        """从池中获取一个可用连接,必要时创建新连接。
118
119        Returns:
120            AsyncSSHClient: 可用的异步客户端
121
122        Raises:
123            SSHConnectionError: 创建连接失败
124            RuntimeError: 连接池已关闭(close_all 之后)
125        """
126        if self._closed:
127            raise RuntimeError("connection pool is closed")
128        await self._semaphore.acquire()
129        # 竞态守卫:等待信号量期间 close_all() 可能已完成——
130        # 取得槽位后必须复查,已关闭则归还槽位并抛出既有错误,
131        # 否则会向调用方发放来自已关闭池的连接
132        if self._closed:
133            self._semaphore.release()
134            raise RuntimeError("connection pool is closed")
135        try:
136            # 优先复用空闲连接
137            while not self._free.empty():
138                conn = self._free.get_nowait()
139                if await self._check_connection(conn):
140                    self._touch(conn)
141                    return conn
142                await self._close_connection(conn)
143
144            # 创建新连接(信号量已保证未超额)
145            return await self._create_connection()
146        except BaseException:
147            self._semaphore.release()
148            raise

从池中获取一个可用连接,必要时创建新连接。

Returns: AsyncSSHClient: 可用的异步客户端

Raises: SSHConnectionError: 创建连接失败 RuntimeError: 连接池已关闭(close_all 之后)

async def release( self, conn: AsyncSSHClient | None) -> None:
150    async def release(self, conn: Optional[AsyncSSHClient]) -> None:
151        """归还连接到池中(如已断开/超时则关闭)。"""
152        if conn is None:
153            return
154        # 池已关闭:不把连接放回空闲队列(避免游离连接),直接关闭并释放槽位
155        if self._closed:
156            await self._close_connection(conn)
157            self._semaphore.release()
158            self._total_released += 1
159            return
160        meta = self._meta.get(id(conn))
161        if meta is not None:
162            meta.last_used = time.time()
163
164        if not conn.is_connected():
165            await self._close_connection(conn)
166            self._semaphore.release()
167            return
168
169        # 生命周期 / 空闲超时则关闭
170        if meta and should_close(meta, self._max_lifetime, self._idle_timeout, True):
171            await self._close_connection(conn)
172            self._semaphore.release()
173            return
174
175        try:
176            async with self._lock:
177                self._free.put_nowait(conn)
178            # 放回 free 后释放许可:free 中的连接不再占用并发槽位,
179            # 后续 acquire 会从 free 直接复用(无需再次获取许可)
180            self._semaphore.release()
181        except asyncio.QueueFull:
182            await self._close_connection(conn)
183            self._semaphore.release()
184        finally:
185            self._total_released += 1

归还连接到池中(如已断开/超时则关闭)。

def stop_monitor(self) -> None:
249    def stop_monitor(self) -> None:
250        if self._monitor_task and not self._monitor_task.done():
251            self._monitor_task.cancel()
def acquire_context(self) -> '_AcquireContext':
300    def acquire_context(self) -> "_AcquireContext":
301        """获取连接的上下文管理器。"""
302        return AsyncConnectionPool._AcquireContext(self)

获取连接的上下文管理器。

async def close_all(self) -> None:
304    async def close_all(self) -> None:
305        """关闭池中所有连接并停止监控。"""
306        self._closed = True
307        self.stop_monitor()
308        for conn in list(self._connections):
309            await self._close_connection(conn)
310        # 释放所有信号量
311        while self._free.empty() is False:
312            self._free.get_nowait()

关闭池中所有连接并停止监控。

class AsyncBatchExecutor:
 50class AsyncBatchExecutor:
 51    """异步批量命令执行器。
 52
 53    Args:
 54        host_service: HostService 实例(提供主机配置与凭据解析)
 55        max_concurrency: 最大并发主机数,默认 10
 56        command_timeout: 单条命令超时(秒),默认 30
 57        pool_factory: 外部连接池工厂(可选)。提供时执行器从工厂获取
 58            池并复用其连接,**绝不关闭**返回的池(所有权归调用方);
 59            未提供时与同步 BatchExecutor 行为对齐——多主机或需重试时
 60            内部按主机创建 AsyncConnectionPool,执行结束后自动关闭。
 61
 62    连接池所有权约定(与同步 BatchExecutor 一致):
 63
 64    - 外部注入(``pool_factory``)→ 调用方拥有,executor 只借用不关闭;
 65      适合长驻服务跨批次复用连接。
 66    - 内部创建 → executor 拥有,单次 ``execute`` 结束后 ``close_all``;
 67      适合一次性脚本。
 68    """
 69
 70    def __init__(
 71        self,
 72        host_service: HostService,
 73        max_concurrency: int = 10,
 74        command_timeout: int = 30,
 75        pool_factory: Optional[PoolFactory] = None,
 76    ) -> None:
 77        if max_concurrency < 1:
 78            raise ValueError(f"max_concurrency must be >= 1, got: {max_concurrency}")
 79        if command_timeout <= 0:
 80            raise ValueError(f"command_timeout must be > 0, got: {command_timeout}")
 81        self._host_service = host_service
 82        self._max_concurrency = max_concurrency
 83        self._command_timeout = command_timeout
 84        self._pool_factory = pool_factory
 85
 86    async def execute(
 87        self,
 88        host_names: list[str],
 89        command: str,
 90        retry_count: int = 0,
 91        retry_delay: float = 1.0,
 92        progress_callback: Optional[ProgressCallback] = None,
 93    ) -> BatchResult:
 94        """在多台主机上异步并发执行同一命令。
 95
 96        Args:
 97            host_names: 主机名称列表
 98            command: 要执行的命令
 99            retry_count: 失败重试次数(默认 0)。仅对瞬态错误
100                (超时/网络中断等)重试;认证、凭据、配置等永久性错误
101                立即失败(分类见 service/retry_policy.py)
102            retry_delay: 重试基础延迟(秒),默认 1.0。实际等待为
103                指数退避 + full jitter:第 n 次失败后等待
104                0 到 retry_delay * 2^n(含端点)内的随机值(上限 60s)
105            progress_callback: 进度回调,签名为 `(completed, total, host_name)`;
106                回调可为同步或 async 函数(async 时会被 await)。
107
108        Returns:
109            BatchResult: 批量结果(与同步 BatchExecutor 完全一致)
110
111        Raises:
112            ValueError: host_names 为空
113        """
114        if not host_names:
115            raise ValueError("host_names must not be empty")
116        if retry_count < 0:
117            raise ValueError(f"retry_count must be >= 0, got: {retry_count}")
118        if retry_delay < 0:
119            raise ValueError(f"retry_delay must be >= 0, got: {retry_delay}")
120
121        # 去重(保留首次出现顺序):重复主机名只执行一次,避免 results 覆盖导致
122        # total/success/failed 统计错位
123        host_names = list(dict.fromkeys(host_names))
124
125        total = len(host_names)
126        semaphore = asyncio.Semaphore(self._max_concurrency)
127        start = time.time()
128
129        logger.info(
130            "异步批量执行开始: %s 台主机, 并发=%s",
131            total,
132            self._max_concurrency,
133        )
134
135        # 连接池准备(与同步 BatchExecutor 对齐):
136        # - 外部 pool_factory 提供时始终启用(调用方持有所有权,绝不关闭)
137        # - 否则多主机或需重试时创建内部池,批结束后统一 close_all
138        pools: dict[str, AsyncConnectionPool] = {}
139        internal_pools: list[AsyncConnectionPool] = []
140        if self._pool_factory is not None or retry_count > 0 or total > 1:
141            for name in host_names:
142                self._prepare_pool(name, pools, internal_pools)
143
144        completed_counter = 0
145        completed_lock = asyncio.Lock()
146        results: dict[str, BatchHostResult] = {}
147
148        async def _per_host(name: str) -> None:
149            nonlocal completed_counter
150            async with semaphore:
151                result = await self._execute_on_host(
152                    name, command, retry_count, retry_delay, pool=pools.get(name)
153                )
154            async with completed_lock:
155                results[name] = result
156                completed = completed_counter + 1
157                completed_counter = completed
158
159                if progress_callback is not None:
160                    # 包裹回调:用户提供的 progress_callback 抛异常时不应中断整个批次,
161                    # 否则 gather 会向上抛出首异常、BatchResult 永不构建、
162                    # 已完成结果丢失且其余 task 沦为孤儿。
163                    try:
164                        rv = progress_callback(completed, total, name)
165                        if asyncio.iscoroutine(rv):
166                            await rv
167                    except Exception as e:  # noqa: BLE001
168                        logger.warning("progress_callback for %s raised: %s", name, e)
169
170                logger.debug(
171                    "[%s/%s] %s: %s (%.1fs)",
172                    completed,
173                    total,
174                    name,
175                    "✓" if result.success else "✗",
176                    result.duration,
177                )
178
179        tasks = [asyncio.create_task(_per_host(n)) for n in host_names]
180        try:
181            # return_exceptions=True 作为兜底:即使 _per_host 意外抛出异常,
182            # 也不会中断其他任务或使 BatchResult 构建被跳过。
183            # KeyboardInterrupt 属于 BaseException,仍会被下方 except 捕获。
184            await asyncio.gather(*tasks, return_exceptions=True)
185        except KeyboardInterrupt:
186            logger.warning("batch execution interrupted by user")
187            await self._cancel_and_mark_interrupted(tasks, host_names, results, command)
188        finally:
189            # 仅关闭内部创建的池;外部 pool_factory 提供的池所有权归调用方
190            await self._cleanup_pools(internal_pools)
191
192        duration = time.time() - start
193        success_count = sum(1 for r in results.values() if r.success)
194        failed_count = total - success_count
195        logger.info(
196            "async batch execution finished: %s/%s succeeded, took %.1fs",
197            success_count,
198            total,
199            duration,
200        )
201
202        return BatchResult(
203            total=total,
204            success=success_count,
205            failed=failed_count,
206            duration=duration,
207            results=results,
208        )
209
210    async def _cancel_and_mark_interrupted(
211        self,
212        tasks: list[asyncio.Task],
213        host_names: list[str],
214        results: dict[str, BatchHostResult],
215        command: str,
216    ) -> None:
217        """用户中断时取消所有任务,并为未完成主机创建失败记录。"""
218        for t in tasks:
219            t.cancel()
220        # 等待取消完成,避免 pending task 告警
221        await asyncio.gather(*tasks, return_exceptions=True)
222        # 为尚未有结果的主机创建失败记录
223        for name in host_names:
224            if name not in results:
225                results[name] = BatchHostResult(
226                    host=name,
227                    success=False,
228                    command=command,
229                    error="user interrupted",
230                )
231
232    def _prepare_pool(
233        self,
234        host_name: str,
235        pools: dict[str, AsyncConnectionPool],
236        internal_pools: list[AsyncConnectionPool],
237    ) -> None:
238        """为指定主机准备连接池(与同步 BatchExecutor._prepare_pool 对称)。
239
240        主机解析失败时跳过(不写入 pools),由 _execute_on_host 的
241        resolve_host_or_error 记录失败条目,保持 execute 的
242        "错误结果而非异常" 契约。
243
244        池所有权:
245        - 外部 ``pool_factory`` 提供的池:调用方持有,绝不登记进
246          internal_pools(executor 不负责关闭)
247        - 内部创建的池:登记进 internal_pools,批结束后统一 close_all
248        """
249        if host_name in pools:
250            return
251        try:
252            host = self._host_service.resolve_host(host_name)
253        except Exception as e:  # noqa: BLE001
254            logger.debug("pool preparation skipped for %s: %s", host_name, e)
255            return
256        config = build_connection_config(host, self._command_timeout)
257        if self._pool_factory is not None:
258            pools[host_name] = self._pool_factory(config)
259            return
260        pool = AsyncConnectionPool(
261            config,
262            max_connections=max(1, self._max_concurrency),
263            client_factory=AsyncSSHClient,
264        )
265        pools[host_name] = pool
266        internal_pools.append(pool)
267
268    async def _cleanup_pools(self, internal_pools: list[AsyncConnectionPool]) -> None:
269        """关闭本批次内部创建的所有连接池(外部提供的池绝不关闭)。"""
270        for pool in internal_pools:
271            await pool.close_all()
272
273    async def _execute_on_host(
274        self,
275        host_name: str,
276        command: str,
277        retry_count: int,
278        retry_delay: float,
279        pool: Optional[AsyncConnectionPool] = None,
280    ) -> BatchHostResult:
281        """在单台主机上异步执行命令(含重试逻辑)。
282
283        Args:
284            host_name: 主机名称
285            command: 要执行的命令
286            retry_count: 重试次数
287            retry_delay: 重试基础延迟(秒,指数退避基准)
288            pool: 可选连接池(外部注入或内部创建),提供时复用连接
289        """
290        # 主机解析(失败返回错误结果)
291        outcome = resolve_host_or_error(self._host_service, host_name, command)
292        if isinstance(outcome, BatchHostResult):
293            return outcome
294        host: Host = outcome
295
296        last_error: Optional[str] = None
297        last_duration = 0.0
298        for attempt in range(retry_count + 1):
299            start = time.time()
300            try:
301                if pool is not None:
302                    # 连接池模式:复用主机连接,避免每次操作握手
303                    # (外部池与内部池语义一致,仅生命周期归属不同)
304                    async with pool.acquire_context() as client:
305                        cmd_result = await client.execute(
306                            command,
307                            timeout=self._command_timeout,
308                        )
309                else:
310                    config = build_connection_config(host, self._command_timeout)
311                    async with AsyncSSHClient(config) as client:
312                        cmd_result = await client.execute(
313                            command,
314                            timeout=self._command_timeout,
315                        )
316                return to_host_result(host_name, command, cmd_result, time.time() - start)
317            except Exception as e:  # noqa: BLE001
318                last_error = str(e)
319                last_duration = time.time() - start
320                logger.debug(
321                    "%s%s/%s 次尝试失败: %s",
322                    host_name,
323                    attempt + 1,
324                    retry_count + 1,
325                    e,
326                )
327                # 已是最后一次尝试,或异常为永久性(认证/凭据/配置错误等),
328                # 立即放弃重试——详见 service/retry_policy.py 的分类契约
329                if attempt >= retry_count:
330                    break
331                if not is_retryable(e):
332                    logger.debug("non-retryable error for %s, giving up: %s", host_name, e)
333                    break
334                # 指数退避 + full jitter(避免多主机同步重试的惊群)
335                delay = compute_backoff_delay(attempt, retry_delay)
336                await asyncio.sleep(delay)
337
338        return BatchHostResult(
339            host=host_name,
340            success=False,
341            command=command,
342            error=last_error,
343            duration=last_duration,
344        )

异步批量命令执行器。

Args: host_service: HostService 实例(提供主机配置与凭据解析) max_concurrency: 最大并发主机数,默认 10 command_timeout: 单条命令超时(秒),默认 30 pool_factory: 外部连接池工厂(可选)。提供时执行器从工厂获取 池并复用其连接,绝不关闭返回的池(所有权归调用方); 未提供时与同步 BatchExecutor 行为对齐——多主机或需重试时 内部按主机创建 AsyncConnectionPool,执行结束后自动关闭。

连接池所有权约定(与同步 BatchExecutor 一致):

  • 外部注入(pool_factory)→ 调用方拥有,executor 只借用不关闭; 适合长驻服务跨批次复用连接。
  • 内部创建 → executor 拥有,单次 execute 结束后 close_all; 适合一次性脚本。
AsyncBatchExecutor( host_service: HostService, max_concurrency: int = 10, command_timeout: int = 30, pool_factory: Callable[[remote_cmd.core.ssh_client.ConnectionConfig], AsyncConnectionPool] | None = None)
70    def __init__(
71        self,
72        host_service: HostService,
73        max_concurrency: int = 10,
74        command_timeout: int = 30,
75        pool_factory: Optional[PoolFactory] = None,
76    ) -> None:
77        if max_concurrency < 1:
78            raise ValueError(f"max_concurrency must be >= 1, got: {max_concurrency}")
79        if command_timeout <= 0:
80            raise ValueError(f"command_timeout must be > 0, got: {command_timeout}")
81        self._host_service = host_service
82        self._max_concurrency = max_concurrency
83        self._command_timeout = command_timeout
84        self._pool_factory = pool_factory
async def execute( self, host_names: list[str], command: str, retry_count: int = 0, retry_delay: float = 1.0, progress_callback: Callable[[int, int, str], Awaitable[None] | None] | None = None) -> BatchResult:
 86    async def execute(
 87        self,
 88        host_names: list[str],
 89        command: str,
 90        retry_count: int = 0,
 91        retry_delay: float = 1.0,
 92        progress_callback: Optional[ProgressCallback] = None,
 93    ) -> BatchResult:
 94        """在多台主机上异步并发执行同一命令。
 95
 96        Args:
 97            host_names: 主机名称列表
 98            command: 要执行的命令
 99            retry_count: 失败重试次数(默认 0)。仅对瞬态错误
100                (超时/网络中断等)重试;认证、凭据、配置等永久性错误
101                立即失败(分类见 service/retry_policy.py)
102            retry_delay: 重试基础延迟(秒),默认 1.0。实际等待为
103                指数退避 + full jitter:第 n 次失败后等待
104                0 到 retry_delay * 2^n(含端点)内的随机值(上限 60s)
105            progress_callback: 进度回调,签名为 `(completed, total, host_name)`;
106                回调可为同步或 async 函数(async 时会被 await)。
107
108        Returns:
109            BatchResult: 批量结果(与同步 BatchExecutor 完全一致)
110
111        Raises:
112            ValueError: host_names 为空
113        """
114        if not host_names:
115            raise ValueError("host_names must not be empty")
116        if retry_count < 0:
117            raise ValueError(f"retry_count must be >= 0, got: {retry_count}")
118        if retry_delay < 0:
119            raise ValueError(f"retry_delay must be >= 0, got: {retry_delay}")
120
121        # 去重(保留首次出现顺序):重复主机名只执行一次,避免 results 覆盖导致
122        # total/success/failed 统计错位
123        host_names = list(dict.fromkeys(host_names))
124
125        total = len(host_names)
126        semaphore = asyncio.Semaphore(self._max_concurrency)
127        start = time.time()
128
129        logger.info(
130            "异步批量执行开始: %s 台主机, 并发=%s",
131            total,
132            self._max_concurrency,
133        )
134
135        # 连接池准备(与同步 BatchExecutor 对齐):
136        # - 外部 pool_factory 提供时始终启用(调用方持有所有权,绝不关闭)
137        # - 否则多主机或需重试时创建内部池,批结束后统一 close_all
138        pools: dict[str, AsyncConnectionPool] = {}
139        internal_pools: list[AsyncConnectionPool] = []
140        if self._pool_factory is not None or retry_count > 0 or total > 1:
141            for name in host_names:
142                self._prepare_pool(name, pools, internal_pools)
143
144        completed_counter = 0
145        completed_lock = asyncio.Lock()
146        results: dict[str, BatchHostResult] = {}
147
148        async def _per_host(name: str) -> None:
149            nonlocal completed_counter
150            async with semaphore:
151                result = await self._execute_on_host(
152                    name, command, retry_count, retry_delay, pool=pools.get(name)
153                )
154            async with completed_lock:
155                results[name] = result
156                completed = completed_counter + 1
157                completed_counter = completed
158
159                if progress_callback is not None:
160                    # 包裹回调:用户提供的 progress_callback 抛异常时不应中断整个批次,
161                    # 否则 gather 会向上抛出首异常、BatchResult 永不构建、
162                    # 已完成结果丢失且其余 task 沦为孤儿。
163                    try:
164                        rv = progress_callback(completed, total, name)
165                        if asyncio.iscoroutine(rv):
166                            await rv
167                    except Exception as e:  # noqa: BLE001
168                        logger.warning("progress_callback for %s raised: %s", name, e)
169
170                logger.debug(
171                    "[%s/%s] %s: %s (%.1fs)",
172                    completed,
173                    total,
174                    name,
175                    "✓" if result.success else "✗",
176                    result.duration,
177                )
178
179        tasks = [asyncio.create_task(_per_host(n)) for n in host_names]
180        try:
181            # return_exceptions=True 作为兜底:即使 _per_host 意外抛出异常,
182            # 也不会中断其他任务或使 BatchResult 构建被跳过。
183            # KeyboardInterrupt 属于 BaseException,仍会被下方 except 捕获。
184            await asyncio.gather(*tasks, return_exceptions=True)
185        except KeyboardInterrupt:
186            logger.warning("batch execution interrupted by user")
187            await self._cancel_and_mark_interrupted(tasks, host_names, results, command)
188        finally:
189            # 仅关闭内部创建的池;外部 pool_factory 提供的池所有权归调用方
190            await self._cleanup_pools(internal_pools)
191
192        duration = time.time() - start
193        success_count = sum(1 for r in results.values() if r.success)
194        failed_count = total - success_count
195        logger.info(
196            "async batch execution finished: %s/%s succeeded, took %.1fs",
197            success_count,
198            total,
199            duration,
200        )
201
202        return BatchResult(
203            total=total,
204            success=success_count,
205            failed=failed_count,
206            duration=duration,
207            results=results,
208        )

在多台主机上异步并发执行同一命令。

Args: host_names: 主机名称列表 command: 要执行的命令 retry_count: 失败重试次数(默认 0)。仅对瞬态错误 (超时/网络中断等)重试;认证、凭据、配置等永久性错误 立即失败(分类见 service/retry_policy.py) retry_delay: 重试基础延迟(秒),默认 1.0。实际等待为 指数退避 + full jitter:第 n 次失败后等待 0 到 retry_delay * 2^n(含端点)内的随机值(上限 60s) progress_callback: 进度回调,签名为 (completed, total, host_name); 回调可为同步或 async 函数(async 时会被 await)。

Returns: BatchResult: 批量结果(与同步 BatchExecutor 完全一致)

Raises: ValueError: host_names 为空

@dataclass
class Host:
 20@dataclass
 21class Host:
 22    """
 23    远程主机配置类
 24
 25    存储单个远程主机的所有连接信息和元数据。
 26    支持通过标签进行分类管理。
 27
 28    Attributes:
 29        name: 主机名称(唯一标识符)
 30        hostname: 主机地址(IP 或域名)
 31        username: SSH 登录用户名
 32        port: SSH 端口号,默认为 22
 33        password: 登录密码(可选,可能被加密)
 34        key_filename: SSH 私钥文件路径(可选)
 35        tags: 主机标签列表,用于分类和筛选
 36        description: 主机描述信息
 37
 38    Example:
 39        >>> host = Host(
 40        ...     name="web-server",
 41        ...     hostname="192.168.1.100",
 42        ...     username="admin",
 43        ...     key_filename="~/.ssh/id_rsa",
 44        ...     tags=["production", "web"]
 45        ... )
 46    """
 47
 48    name: str
 49    hostname: str
 50    username: str
 51    port: int = 22
 52    password: Optional[str] = None
 53    key_filename: Optional[str] = None
 54    tags: list[str] = field(default_factory=list)
 55    description: str = ""
 56
 57    def __post_init__(self):
 58        """初始化后处理:归一化外部传入的 None 标签(兼容旧数据)"""
 59        if self.tags is None:
 60            self.tags = []
 61
 62    def to_connection_config(self) -> ConnectionConfig:
 63        """
 64        将主机配置转换为 SSH 连接配置
 65
 66        Returns:
 67            ConnectionConfig: 可直接用于 SSHClient 的连接配置对象
 68        """
 69        return ConnectionConfig(
 70            hostname=self.hostname,
 71            username=self.username,
 72            port=self.port,
 73            password=self.password,
 74            key_filename=self.key_filename,
 75        )
 76
 77    def to_dict(self) -> dict:
 78        """
 79        将主机配置转换为字典
 80
 81        Returns:
 82            Dict: 包含所有主机属性的字典
 83        """
 84        return asdict(self)
 85
 86    @classmethod
 87    def from_dict(cls, data: dict) -> "Host":
 88        """
 89        从字典创建主机配置对象
 90
 91        Args:
 92            data: 包含主机属性的字典
 93
 94        Returns:
 95            Host: 主机配置对象
 96        """
 97        # 只提取 Host 已知的字段,忽略未知字段
 98        known_fields = {
 99            "name",
100            "hostname",
101            "username",
102            "port",
103            "password",
104            "key_filename",
105            "tags",
106            "description",
107        }
108        filtered = {k: v for k, v in data.items() if k in known_fields}
109        return cls(**filtered)
110
111    def sanitized_dict(self) -> dict[str, Any]:
112        """
113        返回脱敏后的主机字典,用于日志、显示、API 响应等场景。
114
115        敏感字段(password、key_filename)被替换为安全标识。
116
117        Returns:
118            Dict: 不包含敏感明文的主机信息字典
119        """
120        data = asdict(self)
121        # 脱敏密码:显示加密状态而非明文
122        pw = data.get("password")
123        if pw:
124            data["password"] = (
125                "***encrypted***" if isinstance(pw, str) and pw.startswith("$encrypted$") else "***"
126            )
127        # 脱敏密钥路径:仅显示文件名
128        if data.get("key_filename"):
129            data["key_filename"] = Path(data["key_filename"]).name
130        return data
131
132    def __repr__(self) -> str:
133        """安全的字符串表示:自动脱敏敏感字段"""
134        safe = self.sanitized_dict()
135        fields = ", ".join(f"{k}={v!r}" for k, v in safe.items())
136        return f"Host({fields})"

远程主机配置类

存储单个远程主机的所有连接信息和元数据。 支持通过标签进行分类管理。

Attributes: name: 主机名称(唯一标识符) hostname: 主机地址(IP 或域名) username: SSH 登录用户名 port: SSH 端口号,默认为 22 password: 登录密码(可选,可能被加密) key_filename: SSH 私钥文件路径(可选) tags: 主机标签列表,用于分类和筛选 description: 主机描述信息

Example:

host = Host( ... name="web-server", ... hostname="192.168.1.100", ... username="admin", ... key_filename="~/.ssh/id_rsa", ... tags=["production", "web"] ... )

Host( name: str, hostname: str, username: str, port: int = 22, password: str | None = None, key_filename: str | None = None, tags: list[str] = <factory>, description: str = '')
name: str
hostname: str
username: str
port: int = 22
password: str | None = None
key_filename: str | None = None
tags: list[str]
description: str = ''
def to_connection_config(self) -> remote_cmd.core.ssh_client.ConnectionConfig:
62    def to_connection_config(self) -> ConnectionConfig:
63        """
64        将主机配置转换为 SSH 连接配置
65
66        Returns:
67            ConnectionConfig: 可直接用于 SSHClient 的连接配置对象
68        """
69        return ConnectionConfig(
70            hostname=self.hostname,
71            username=self.username,
72            port=self.port,
73            password=self.password,
74            key_filename=self.key_filename,
75        )

将主机配置转换为 SSH 连接配置

Returns: ConnectionConfig: 可直接用于 SSHClient 的连接配置对象

def to_dict(self) -> dict:
77    def to_dict(self) -> dict:
78        """
79        将主机配置转换为字典
80
81        Returns:
82            Dict: 包含所有主机属性的字典
83        """
84        return asdict(self)

将主机配置转换为字典

Returns: Dict: 包含所有主机属性的字典

@classmethod
def from_dict(cls, data: dict) -> 'Host':
 86    @classmethod
 87    def from_dict(cls, data: dict) -> "Host":
 88        """
 89        从字典创建主机配置对象
 90
 91        Args:
 92            data: 包含主机属性的字典
 93
 94        Returns:
 95            Host: 主机配置对象
 96        """
 97        # 只提取 Host 已知的字段,忽略未知字段
 98        known_fields = {
 99            "name",
100            "hostname",
101            "username",
102            "port",
103            "password",
104            "key_filename",
105            "tags",
106            "description",
107        }
108        filtered = {k: v for k, v in data.items() if k in known_fields}
109        return cls(**filtered)

从字典创建主机配置对象

Args: data: 包含主机属性的字典

Returns: Host: 主机配置对象

def sanitized_dict(self) -> dict[str, typing.Any]:
111    def sanitized_dict(self) -> dict[str, Any]:
112        """
113        返回脱敏后的主机字典,用于日志、显示、API 响应等场景。
114
115        敏感字段(password、key_filename)被替换为安全标识。
116
117        Returns:
118            Dict: 不包含敏感明文的主机信息字典
119        """
120        data = asdict(self)
121        # 脱敏密码:显示加密状态而非明文
122        pw = data.get("password")
123        if pw:
124            data["password"] = (
125                "***encrypted***" if isinstance(pw, str) and pw.startswith("$encrypted$") else "***"
126            )
127        # 脱敏密钥路径:仅显示文件名
128        if data.get("key_filename"):
129            data["key_filename"] = Path(data["key_filename"]).name
130        return data

返回脱敏后的主机字典,用于日志、显示、API 响应等场景。

敏感字段(password、key_filename)被替换为安全标识。

Returns: Dict: 不包含敏感明文的主机信息字典

class HostManager:
 27class HostManager:
 28    """
 29    主机管理器(向后兼容版本)
 30
 31    保持原有 API 签名不变,内部委托给 HostService + JsonHostRepository。
 32
 33    已弃用: 请使用 HostService + HostRepository 替代
 34    """
 35
 36    def __init__(self, hosts_file: Optional[str] = None):
 37        """
 38        初始化主机管理器
 39
 40        Args:
 41            hosts_file: 配置文件路径(可选)
 42        """
 43        self.hosts_file = hosts_file
 44
 45        # 不指定文件时使用纯内存模式(保持向后兼容)
 46        if hosts_file:
 47            self._repo = JsonHostRepository(filepath=hosts_file, auto_load=True)
 48        else:
 49            self._repo = JsonHostRepository(filepath="hosts.json", auto_load=False)
 50
 51        self._service = HostService(repository=self._repo)
 52
 53        # 保持原有属性访问兼容
 54        self.hosts: dict[str, Host] = {}
 55        self._sync_hosts()
 56
 57    def _sync_hosts(self):
 58        """同步 self.hosts 字典以保持向后兼容"""
 59        self.hosts = {h.name: h for h in self._repo.list()}
 60
 61    # ========================================================================
 62    # 主机管理方法
 63    # ========================================================================
 64
 65    def add_host(self, host: Host) -> None:
 66        self._service.add_host(host)
 67        self._sync_hosts()
 68
 69    def update_host(self, name: str, **kwargs) -> Host:
 70        host = self._service.update_host(name, **kwargs)
 71        self._sync_hosts()
 72        return host
 73
 74    def remove_host(self, name: str) -> None:
 75        self._service.remove_host(name)
 76        self._sync_hosts()
 77
 78    def get_host(self, name: str) -> Host:
 79        return self._service.get_host(name)
 80
 81    def list_hosts(self, tag: Optional[str] = None) -> list[Host]:
 82        return self._service.list_hosts(tag=tag)
 83
 84    def list_tags(self) -> list[str]:
 85        return self._service.list_tags()
 86
 87    # ========================================================================
 88    # 持久化方法
 89    # ========================================================================
 90
 91    def save_to_file(self, filepath: str) -> None:
 92        """
 93        保存配置到文件
 94
 95        注意: JsonHostRepository 使用 atomic write。
 96        如果 filepath 与初始化时的不同,会更新 repo 的路径。
 97        """
 98        from pathlib import Path
 99
100        current_path = str(self._repo._filepath)
101        if Path(current_path) != Path(filepath):
102            # 关键安全:使用 repo.list() 获取原始(已加密)主机数据,
103            # 而非 service.list_hosts()(会解密为明文)。
104            # 否则新建的未配置 encryption 的 JsonHostRepository 在 flush() 时
105            # 会将明文密码直接写入磁盘,造成凭据泄露。
106            encrypted_hosts = {h.name: h for h in self._repo.list()}
107            # 重新初始化 repo
108            self._repo = JsonHostRepository(filepath=filepath, auto_load=False)
109            self._repo.load_from_dict(encrypted_hosts)
110        self._repo.flush()
111        self._sync_hosts()
112
113    def load_from_file(self, filepath: str) -> None:
114        """从文件加载配置"""
115        self._repo = JsonHostRepository(filepath=filepath, auto_load=True)
116        self._service = HostService(repository=self._repo)
117        self._sync_hosts()
118
119    # ========================================================================
120    # 连接测试方法
121    # ========================================================================
122
123    def connect_to_host(self, name: str) -> SSHClient:
124        return self._service.connect_to_host(name)
125
126    def test_connection(self, name: str) -> bool:
127        """测试主机连接(通过 connect_to_host 保持 monkeypatch 兼容)"""
128        try:
129            with self.connect_to_host(name) as client:
130                return client.is_connected()
131        except (OSError, SSHConnectionError) as e:
132            logger.error(f"主机 {name} 连接测试失败: {e}")
133            return False
134
135    def test_all_connections(self, max_workers: int = 10) -> dict[str, bool]:
136        """并行测试所有主机(通过 test_connection 保持 monkeypatch 兼容)"""
137        results: dict[str, bool] = {}
138        host_names = list(self.hosts.keys())
139
140        with ThreadPoolExecutor(max_workers=max_workers) as executor:
141            future_map = {executor.submit(self.test_connection, name): name for name in host_names}
142            for future in as_completed(future_map):
143                name = future_map[future]
144                try:
145                    results[name] = future.result()
146                except Exception as e:  # noqa: BLE001
147                    logger.error(f"主机 {name} 连接测试异常: {e}")
148                    results[name] = False
149
150        return results
151
152    # ========================================================================
153    # 魔术方法
154    # ========================================================================
155
156    def __enter__(self) -> "HostManager":
157        return self
158
159    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
160        pass
161
162    def __len__(self) -> int:
163        return self._repo.count()
164
165    def __contains__(self, name: str) -> bool:
166        return self._repo.contains(name)

主机管理器(向后兼容版本)

保持原有 API 签名不变,内部委托给 HostService + JsonHostRepository。

已弃用: 请使用 HostService + HostRepository 替代

HostManager(hosts_file: str | None = None)
36    def __init__(self, hosts_file: Optional[str] = None):
37        """
38        初始化主机管理器
39
40        Args:
41            hosts_file: 配置文件路径(可选)
42        """
43        self.hosts_file = hosts_file
44
45        # 不指定文件时使用纯内存模式(保持向后兼容)
46        if hosts_file:
47            self._repo = JsonHostRepository(filepath=hosts_file, auto_load=True)
48        else:
49            self._repo = JsonHostRepository(filepath="hosts.json", auto_load=False)
50
51        self._service = HostService(repository=self._repo)
52
53        # 保持原有属性访问兼容
54        self.hosts: dict[str, Host] = {}
55        self._sync_hosts()

初始化主机管理器

Args: hosts_file: 配置文件路径(可选)

hosts_file
hosts: 'dict[str, Host]'
def add_host(self, host: Host) -> None:
65    def add_host(self, host: Host) -> None:
66        self._service.add_host(host)
67        self._sync_hosts()
def update_host(self, name: str, **kwargs) -> Host:
69    def update_host(self, name: str, **kwargs) -> Host:
70        host = self._service.update_host(name, **kwargs)
71        self._sync_hosts()
72        return host
def remove_host(self, name: str) -> None:
74    def remove_host(self, name: str) -> None:
75        self._service.remove_host(name)
76        self._sync_hosts()
def get_host(self, name: str) -> Host:
78    def get_host(self, name: str) -> Host:
79        return self._service.get_host(name)
def list_hosts(self, tag: str | None = None) -> list[Host]:
81    def list_hosts(self, tag: Optional[str] = None) -> list[Host]:
82        return self._service.list_hosts(tag=tag)
def list_tags(self) -> list[str]:
84    def list_tags(self) -> list[str]:
85        return self._service.list_tags()
def save_to_file(self, filepath: str) -> None:
 91    def save_to_file(self, filepath: str) -> None:
 92        """
 93        保存配置到文件
 94
 95        注意: JsonHostRepository 使用 atomic write。
 96        如果 filepath 与初始化时的不同,会更新 repo 的路径。
 97        """
 98        from pathlib import Path
 99
100        current_path = str(self._repo._filepath)
101        if Path(current_path) != Path(filepath):
102            # 关键安全:使用 repo.list() 获取原始(已加密)主机数据,
103            # 而非 service.list_hosts()(会解密为明文)。
104            # 否则新建的未配置 encryption 的 JsonHostRepository 在 flush() 时
105            # 会将明文密码直接写入磁盘,造成凭据泄露。
106            encrypted_hosts = {h.name: h for h in self._repo.list()}
107            # 重新初始化 repo
108            self._repo = JsonHostRepository(filepath=filepath, auto_load=False)
109            self._repo.load_from_dict(encrypted_hosts)
110        self._repo.flush()
111        self._sync_hosts()

保存配置到文件

注意: JsonHostRepository 使用 atomic write。 如果 filepath 与初始化时的不同,会更新 repo 的路径。

def load_from_file(self, filepath: str) -> None:
113    def load_from_file(self, filepath: str) -> None:
114        """从文件加载配置"""
115        self._repo = JsonHostRepository(filepath=filepath, auto_load=True)
116        self._service = HostService(repository=self._repo)
117        self._sync_hosts()

从文件加载配置

def connect_to_host(self, name: str) -> SSHClient:
123    def connect_to_host(self, name: str) -> SSHClient:
124        return self._service.connect_to_host(name)
def test_connection(self, name: str) -> bool:
126    def test_connection(self, name: str) -> bool:
127        """测试主机连接(通过 connect_to_host 保持 monkeypatch 兼容)"""
128        try:
129            with self.connect_to_host(name) as client:
130                return client.is_connected()
131        except (OSError, SSHConnectionError) as e:
132            logger.error(f"主机 {name} 连接测试失败: {e}")
133            return False

测试主机连接(通过 connect_to_host 保持 monkeypatch 兼容)

def test_all_connections(self, max_workers: int = 10) -> dict[str, bool]:
135    def test_all_connections(self, max_workers: int = 10) -> dict[str, bool]:
136        """并行测试所有主机(通过 test_connection 保持 monkeypatch 兼容)"""
137        results: dict[str, bool] = {}
138        host_names = list(self.hosts.keys())
139
140        with ThreadPoolExecutor(max_workers=max_workers) as executor:
141            future_map = {executor.submit(self.test_connection, name): name for name in host_names}
142            for future in as_completed(future_map):
143                name = future_map[future]
144                try:
145                    results[name] = future.result()
146                except Exception as e:  # noqa: BLE001
147                    logger.error(f"主机 {name} 连接测试异常: {e}")
148                    results[name] = False
149
150        return results

并行测试所有主机(通过 test_connection 保持 monkeypatch 兼容)

class HostRepository(abc.ABC):
17class HostRepository(ABC):
18    """主机配置仓库抽象基类"""
19
20    @abstractmethod
21    def save(self, host: Host) -> None:
22        """保存主机(新增或覆盖)"""
23        ...
24
25    @abstractmethod
26    def get(self, name: str) -> Host:
27        """按名称获取主机,不存在时抛出 KeyError"""
28        ...
29
30    @abstractmethod
31    def delete(self, name: str) -> None:
32        """按名称删除主机,不存在时抛出 KeyError"""
33        ...
34
35    @abstractmethod
36    def list(self, tag: Optional[str] = None) -> list[Host]:
37        """列出主机,可选按标签筛选"""
38        ...
39
40    @abstractmethod
41    def list_tags(self) -> builtins.list[str]:
42        """列出所有标签"""
43        ...
44
45    @abstractmethod
46    def contains(self, name: str) -> bool:
47        """检查主机是否存在"""
48        ...
49
50    @abstractmethod
51    def count(self) -> int:
52        """返回主机数量"""
53        ...
54
55    @abstractmethod
56    def flush(self) -> None:
57        """将所有内存中更改写入存储"""
58        ...

主机配置仓库抽象基类

@abstractmethod
def save(self, host: Host) -> None:
20    @abstractmethod
21    def save(self, host: Host) -> None:
22        """保存主机(新增或覆盖)"""
23        ...

保存主机(新增或覆盖)

@abstractmethod
def get(self, name: str) -> Host:
25    @abstractmethod
26    def get(self, name: str) -> Host:
27        """按名称获取主机,不存在时抛出 KeyError"""
28        ...

按名称获取主机,不存在时抛出 KeyError

@abstractmethod
def delete(self, name: str) -> None:
30    @abstractmethod
31    def delete(self, name: str) -> None:
32        """按名称删除主机,不存在时抛出 KeyError"""
33        ...

按名称删除主机,不存在时抛出 KeyError

@abstractmethod
def list(unknown):
35    @abstractmethod
36    def list(self, tag: Optional[str] = None) -> list[Host]:
37        """列出主机,可选按标签筛选"""
38        ...

列出主机,可选按标签筛选

@abstractmethod
def list_tags(self) -> list[str]:
40    @abstractmethod
41    def list_tags(self) -> builtins.list[str]:
42        """列出所有标签"""
43        ...

列出所有标签

@abstractmethod
def contains(self, name: str) -> bool:
45    @abstractmethod
46    def contains(self, name: str) -> bool:
47        """检查主机是否存在"""
48        ...

检查主机是否存在

@abstractmethod
def count(self) -> int:
50    @abstractmethod
51    def count(self) -> int:
52        """返回主机数量"""
53        ...

返回主机数量

@abstractmethod
def flush(self) -> None:
55    @abstractmethod
56    def flush(self) -> None:
57        """将所有内存中更改写入存储"""
58        ...

将所有内存中更改写入存储

class JsonHostRepository(remote_cmd.HostRepository):
 32class JsonHostRepository(HostRepository):
 33    """
 34    JSON 文件主机仓库
 35
 36    Args:
 37        filepath: JSON 文件路径
 38        encryption: 可选的凭据加密器(设置后自动加密 password)
 39        auto_load: 初始化时是否自动加载已有文件(默认 True)
 40    """
 41
 42    def __init__(
 43        self,
 44        filepath: str,
 45        encryption: Optional[CredentialEncryption] = None,
 46        auto_load: bool = True,
 47    ) -> None:
 48        self._filepath = Path(filepath)
 49        self._encryption = encryption
 50        self._guard = PasswordGuard(encryption)
 51        self._hosts: dict[str, Host] = {}
 52
 53        if auto_load and self._filepath.exists():
 54            self._load()
 55
 56    # ========================================================================
 57    # Repository 接口实现
 58    # ========================================================================
 59
 60    def save(self, host: Host) -> None:
 61        """
 62        保存主机到内存,随后需要调用 flush() 写入文件
 63
 64        注意: 本方法不会加密密码。password 的加密发生在 flush() 序列化阶段
 65        (仅当构造时传入了 encryption)。请勿绕过 HostService 直接以明文
 66        密码调用 save() 后再 flush() 落盘——确保传入了 encryption。
 67        """
 68        self._hosts[host.name] = host
 69
 70    def get(self, name: str) -> Host:
 71        if name not in self._hosts:
 72            raise KeyError(f"Host '{name}' not found")
 73        return self._hosts[name]
 74
 75    def delete(self, name: str) -> None:
 76        if name not in self._hosts:
 77            raise KeyError(f"Host '{name}' not found")
 78        del self._hosts[name]
 79
 80    def list(self, tag: Optional[str] = None) -> list[Host]:
 81        hosts = list(self._hosts.values())
 82        if tag:
 83            hosts = [h for h in hosts if h.tags and tag in h.tags]
 84        return hosts
 85
 86    def list_tags(self) -> builtins.list[str]:
 87        tags: set = set()
 88        for host in self._hosts.values():
 89            if host.tags:
 90                tags.update(host.tags)
 91        return sorted(tags)
 92
 93    def contains(self, name: str) -> bool:
 94        return name in self._hosts
 95
 96    def count(self) -> int:
 97        return len(self._hosts)
 98
 99    # ========================================================================
100    # 持久化
101    # ========================================================================
102
103    def flush(self) -> None:
104        """原子写入 JSON 文件"""
105        data = self._serialize_hosts()
106        self._atomic_write(data)
107
108    def _serialize_hosts(self) -> dict:
109        """序列化主机列表到字典,包含版本信息"""
110        hosts_dict = {name: host.to_dict() for name, host in self._hosts.items()}
111
112        # 加密密码
113        if self._guard.enabled:
114            for host_data in hosts_dict.values():
115                host_data["password"] = self._guard.encrypt(host_data.get("password"))
116
117        return {
118            "version": CONFIG_VERSION,
119            "hosts": hosts_dict,
120        }
121
122    def _load(self) -> None:
123        """从 JSON 文件加载主机配置"""
124        try:
125            with open(self._filepath, encoding="utf-8") as f:
126                raw = json.load(f)
127        except (json.JSONDecodeError, FileNotFoundError) as e:
128            logger.warning(f"failed to load config file: {e}")
129            return
130
131        # 检查版本并迁移
132        version = raw.get("version", 1)
133        if version < CONFIG_VERSION:
134            logger.info(f"config version {version} -> {CONFIG_VERSION},running migration")
135
136        hosts_data = raw.get("hosts", raw if version == 1 else {})
137        # 兼容 v1 格式(hosts 直接在最外层)
138        if version == 1 and isinstance(hosts_data, dict):
139            pass  # hosts_data 已经是正确的格式
140
141        self._hosts = {}
142        for name, host_data in hosts_data.items():
143            pw = host_data.get("password")
144            if self._guard.is_encrypted(pw):
145                resolved = self._guard.decrypt(pw)
146                if resolved is None:
147                    logger.error(f"decrypting password for host '{name}' failed")
148                host_data["password"] = resolved
149
150            try:
151                host = Host.from_dict(host_data)
152                self._hosts[name] = host
153            except (ValueError, TypeError, KeyError) as e:
154                logger.warning(f"skipping invalid host '{name}': {e}")
155
156    def _atomic_write(self, data: dict) -> None:
157        """原子写入:写临时文件 → rename 覆盖原文件"""
158        self._filepath.parent.mkdir(parents=True, exist_ok=True)
159
160        fd, tmp_path = tempfile.mkstemp(
161            suffix=".tmp",
162            prefix=f"{self._filepath.name}.",
163            dir=self._filepath.parent,
164        )
165        try:
166            with os.fdopen(fd, "w", encoding="utf-8") as f:
167                json.dump(data, f, indent=2, ensure_ascii=False)
168            os.replace(tmp_path, str(self._filepath))
169        except Exception:
170            # 清理临时文件
171            with contextlib.suppress(OSError):
172                os.unlink(tmp_path)
173            raise
174
175        logger.debug(f"saved {self.count()} host configs to {self._filepath}")
176
177    # ========================================================================
178    # 批量操作
179    # ========================================================================
180
181    def load_from_dict(self, data: dict[str, Host]) -> None:
182        """从字典批量加载主机(替换当前所有)"""
183        self._hosts = dict(data)
184
185    def to_dict(self) -> dict[str, Host]:
186        """导出所有主机的字典"""
187        return dict(self._hosts)

JSON 文件主机仓库

Args: filepath: JSON 文件路径 encryption: 可选的凭据加密器(设置后自动加密 password) auto_load: 初始化时是否自动加载已有文件(默认 True)

JsonHostRepository( filepath: str, encryption: CredentialEncryption | None = None, auto_load: bool = True)
42    def __init__(
43        self,
44        filepath: str,
45        encryption: Optional[CredentialEncryption] = None,
46        auto_load: bool = True,
47    ) -> None:
48        self._filepath = Path(filepath)
49        self._encryption = encryption
50        self._guard = PasswordGuard(encryption)
51        self._hosts: dict[str, Host] = {}
52
53        if auto_load and self._filepath.exists():
54            self._load()
def save(self, host: Host) -> None:
60    def save(self, host: Host) -> None:
61        """
62        保存主机到内存,随后需要调用 flush() 写入文件
63
64        注意: 本方法不会加密密码。password 的加密发生在 flush() 序列化阶段
65        (仅当构造时传入了 encryption)。请勿绕过 HostService 直接以明文
66        密码调用 save() 后再 flush() 落盘——确保传入了 encryption。
67        """
68        self._hosts[host.name] = host

保存主机到内存,随后需要调用 flush() 写入文件

注意: 本方法不会加密密码。password 的加密发生在 flush() 序列化阶段 (仅当构造时传入了 encryption)。请勿绕过 HostService 直接以明文 密码调用 save() 后再 flush() 落盘——确保传入了 encryption。

def get(self, name: str) -> Host:
70    def get(self, name: str) -> Host:
71        if name not in self._hosts:
72            raise KeyError(f"Host '{name}' not found")
73        return self._hosts[name]

按名称获取主机,不存在时抛出 KeyError

def delete(self, name: str) -> None:
75    def delete(self, name: str) -> None:
76        if name not in self._hosts:
77            raise KeyError(f"Host '{name}' not found")
78        del self._hosts[name]

按名称删除主机,不存在时抛出 KeyError

def list(unknown):
80    def list(self, tag: Optional[str] = None) -> list[Host]:
81        hosts = list(self._hosts.values())
82        if tag:
83            hosts = [h for h in hosts if h.tags and tag in h.tags]
84        return hosts

列出主机,可选按标签筛选

def list_tags(self) -> list[str]:
86    def list_tags(self) -> builtins.list[str]:
87        tags: set = set()
88        for host in self._hosts.values():
89            if host.tags:
90                tags.update(host.tags)
91        return sorted(tags)

列出所有标签

def contains(self, name: str) -> bool:
93    def contains(self, name: str) -> bool:
94        return name in self._hosts

检查主机是否存在

def count(self) -> int:
96    def count(self) -> int:
97        return len(self._hosts)

返回主机数量

def flush(self) -> None:
103    def flush(self) -> None:
104        """原子写入 JSON 文件"""
105        data = self._serialize_hosts()
106        self._atomic_write(data)

原子写入 JSON 文件

def load_from_dict(self, data: dict[str, Host]) -> None:
181    def load_from_dict(self, data: dict[str, Host]) -> None:
182        """从字典批量加载主机(替换当前所有)"""
183        self._hosts = dict(data)

从字典批量加载主机(替换当前所有)

def to_dict(self) -> dict[str, Host]:
185    def to_dict(self) -> dict[str, Host]:
186        """导出所有主机的字典"""
187        return dict(self._hosts)

导出所有主机的字典

class HostService:
 46class HostService:
 47    """
 48    主机业务逻辑服务
 49
 50    Args:
 51        repository: 主机配置仓库
 52        credential_provider: 凭据提供者(可选)
 53        encryption: 密码加密器(可选)
 54        ssh_service: SSH 连接服务(可选,自动创建)
 55    """
 56
 57    def __init__(
 58        self,
 59        repository: HostRepository,
 60        credential_provider: Optional[CredentialProvider] = None,
 61        encryption: Optional[CredentialEncryption] = None,
 62        ssh_service: Optional[SSHService] = None,
 63    ) -> None:
 64        self._repo = repository
 65        self._encryption = encryption or CredentialEncryption()
 66        self._ssh = ssh_service or SSHService()
 67
 68        if credential_provider:
 69            self._cred_provider = credential_provider
 70        else:
 71            # 默认凭据链: 环境变量 → 加密文件
 72            self._cred_provider = ChainCredentialProvider(
 73                [
 74                    EnvCredentialProvider(),
 75                    EncryptedFileCredentialProvider(repository, self._encryption),
 76                ]
 77            )
 78
 79    # ========================================================================
 80    # 主机管理
 81    # ========================================================================
 82
 83    def add_host(self, host: Host) -> Host:
 84        """
 85        添加主机
 86
 87        Args:
 88            host: 主机配置
 89
 90        Returns:
 91            Host: 已添加的主机
 92
 93        Raises:
 94            ValueError: 同名主机已存在
 95        """
 96        if self._repo.contains(host.name):
 97            raise ValueError(f"Host '{host.name}' already exists")
 98
 99        # 加密密码(如果明文)
100        if host.password and not self._encryption.is_encrypted(host.password):
101            host.password = self._encryption.encrypt(host.password)
102
103        self._repo.save(host)
104        self._repo.flush()
105        logger.info(f"host added: {host.name}")
106        return host
107
108    def get_host(self, name: str) -> Host:
109        """获取主机配置(密码自动解密)"""
110        host = self._repo.get(name)
111        return self._decrypt_host(host)
112
113    def update_host(self, name: str, **kwargs) -> Host:
114        """
115        更新主机配置
116
117        Args:
118            name: 主机名
119            **kwargs: 要更新的字段
120
121        Returns:
122            Host: 更新后的主机
123        """
124        host = self._repo.get(name)
125        for key, value in kwargs.items():
126            if hasattr(host, key):
127                setattr(host, key, value)
128
129        # 如果密码被更新,重新加密
130        if (
131            "password" in kwargs
132            and kwargs["password"] is not None
133            and not self._encryption.is_encrypted(kwargs["password"])
134        ):
135            host.password = self._encryption.encrypt(kwargs["password"])
136
137        self._repo.save(host)
138        self._repo.flush()
139        logger.info(f"host updated: {name}")
140        return host
141
142    def remove_host(self, name: str) -> None:
143        """删除主机"""
144        self._repo.delete(name)
145        self._repo.flush()
146        logger.info(f"host removed: {name}")
147
148    def list_hosts(self, tag: Optional[str] = None) -> list[Host]:
149        """列出主机(密码自动解密)"""
150        hosts = self._repo.list(tag=tag)
151        return [self._decrypt_host(h) for h in hosts]
152
153    def list_tags(self) -> list[str]:
154        """列出所有标签"""
155        return self._repo.list_tags()
156
157    # ========================================================================
158    # 连接管理
159    # ========================================================================
160
161    def connect_to_host(self, name: str) -> SSHClient:
162        """
163        建立到主机的 SSH 连接
164
165        Args:
166            name: 主机名
167
168        Returns:
169            SSHClient: 已连接的客户端
170        """
171        host = self.resolve_host(name)
172        return self._ssh.create_client(**self._to_ssh_args(host))
173
174    def test_connection(self, name: str) -> bool:
175        """
176        测试主机连接
177
178        Args:
179            name: 主机名
180
181        Returns:
182            bool: True if connected
183        """
184        host = self.resolve_host(name)
185        return self._ssh.test_connection(**self._to_ssh_args(host))
186
187    def test_all_connections(self, max_workers: int = 10) -> dict[str, bool]:
188        """并行测试所有主机连接"""
189        from concurrent.futures import ThreadPoolExecutor, as_completed
190
191        hosts = self._repo.list()
192        results: dict[str, bool] = {}
193
194        with ThreadPoolExecutor(max_workers=max_workers) as executor:
195            future_map = {executor.submit(self.test_connection, h.name): h.name for h in hosts}
196            for future in as_completed(future_map):
197                name = future_map[future]
198                try:
199                    results[name] = future.result()
200                except Exception as e:  # noqa: BLE001
201                    logger.error(f"connection test error for host {name}: {e}")
202                    results[name] = False
203
204        return results
205
206    def _build_resolved_host(
207        self,
208        host: Host,
209        password: Optional[str],
210        key_filename: Optional[str],
211    ) -> Host:
212        """构造带解析后凭据的 Host 副本,绝不修改仓库内存中的原对象。
213
214        Args:
215            host: 仓库中的原始主机对象
216            password: 解析后的密码(明文或加密 token)
217            key_filename: 解析后的私钥路径
218
219        Returns:
220            Host: 新副本
221        """
222        return Host(
223            name=host.name,
224            hostname=host.hostname,
225            username=host.username,
226            port=host.port,
227            password=password,
228            key_filename=key_filename,
229            tags=host.tags,
230            description=host.description,
231        )
232
233    def _to_ssh_args(self, host: Host) -> dict[str, Any]:
234        """从主机构造 SSH 服务的连接参数字典(create_client / test_connection 共用)。"""
235        return {
236            "hostname": host.hostname,
237            "username": host.username,
238            "port": host.port,
239            "password": host.password,
240            "key_filename": host.key_filename,
241        }
242
243    def _decrypt_host(self, host: Host) -> Host:
244        """返回主机副本,密码字段自动解密(如已加密)"""
245        if host.password and self._encryption.is_encrypted(host.password):
246            try:
247                decrypted = self._encryption.decrypt(host.password)
248                return self._build_resolved_host(host, decrypted, host.key_filename)
249            except Exception as e:  # noqa: BLE001
250                # 解密失败不应阻塞整批主机返回:保留加密 token,
251                # 让 SSH 层在真正连接时报告认证失败
252                logger.warning(f"failed to decrypt password for {host.name}: {e}")
253        return host
254
255    def resolve_host(self, name: str) -> Host:
256        """
257        获取主机并尝试解密密码
258
259        解密优先级:
260        1. 通过凭据提供链(环境变量 / keyring / 加密文件存储等)获取明文
261        2. 若凭据链未命中,则回退到本地 CredentialEncryption 解密存储中的加密 token
262
263        这一层兜底是必需的:CLI 的默认凭据链可能不包含
264        EncryptedFileCredentialProvider,但主机密码已被 add_host 加密落盘,
265        若不兜底解密则 connect_to_host 会拿到加密 token 当密码使用,必然认证失败。
266
267        Note:
268            本方法返回一个新的 Host 副本,绝不就地修改仓库内存中存储的对象。
269            若直接修改 repo.get() 返回的原始引用,解密后的明文密码会污染内存
270            中的加密 token,后续任意 add_host/update_host/remove_host 触发
271            flush() 时明文密码会被写入磁盘,造成凭据泄露。
272        """
273        host = self._repo.get(name)
274
275        # 解析密码:始终写入新变量,不修改 host 原对象
276        resolved_password = host.password
277        if host.password and self._encryption.is_encrypted(host.password):
278            resolved = self._cred_provider.get_password(host)
279            if resolved:
280                resolved_password = resolved
281            else:
282                # 凭据链未命中,回退到本地加密器解密
283                try:
284                    resolved_password = self._encryption.decrypt(host.password)
285                except Exception as e:  # noqa: BLE001
286                    logger.warning(f"failed to decrypt password for {host.name}: {e}")
287                    # 保留加密 token,留给 SSH 层报认证失败
288
289        # 解析密钥路径
290        resolved_key_filename = host.key_filename
291        if resolved_key_filename:
292            resolved_key_filename = str(Path(resolved_key_filename).expanduser())
293
294        # 返回新对象,保持仓库内存中的加密 token 不被污染
295        return self._build_resolved_host(host, resolved_password, resolved_key_filename)

主机业务逻辑服务

Args: repository: 主机配置仓库 credential_provider: 凭据提供者(可选) encryption: 密码加密器(可选) ssh_service: SSH 连接服务(可选,自动创建)

HostService( repository: HostRepository, credential_provider: CredentialProvider | None = None, encryption: CredentialEncryption | None = None, ssh_service: SSHService | None = None)
57    def __init__(
58        self,
59        repository: HostRepository,
60        credential_provider: Optional[CredentialProvider] = None,
61        encryption: Optional[CredentialEncryption] = None,
62        ssh_service: Optional[SSHService] = None,
63    ) -> None:
64        self._repo = repository
65        self._encryption = encryption or CredentialEncryption()
66        self._ssh = ssh_service or SSHService()
67
68        if credential_provider:
69            self._cred_provider = credential_provider
70        else:
71            # 默认凭据链: 环境变量 → 加密文件
72            self._cred_provider = ChainCredentialProvider(
73                [
74                    EnvCredentialProvider(),
75                    EncryptedFileCredentialProvider(repository, self._encryption),
76                ]
77            )
def add_host(self, host: Host) -> Host:
 83    def add_host(self, host: Host) -> Host:
 84        """
 85        添加主机
 86
 87        Args:
 88            host: 主机配置
 89
 90        Returns:
 91            Host: 已添加的主机
 92
 93        Raises:
 94            ValueError: 同名主机已存在
 95        """
 96        if self._repo.contains(host.name):
 97            raise ValueError(f"Host '{host.name}' already exists")
 98
 99        # 加密密码(如果明文)
100        if host.password and not self._encryption.is_encrypted(host.password):
101            host.password = self._encryption.encrypt(host.password)
102
103        self._repo.save(host)
104        self._repo.flush()
105        logger.info(f"host added: {host.name}")
106        return host

添加主机

Args: host: 主机配置

Returns: Host: 已添加的主机

Raises: ValueError: 同名主机已存在

def get_host(self, name: str) -> Host:
108    def get_host(self, name: str) -> Host:
109        """获取主机配置(密码自动解密)"""
110        host = self._repo.get(name)
111        return self._decrypt_host(host)

获取主机配置(密码自动解密)

def update_host(self, name: str, **kwargs) -> Host:
113    def update_host(self, name: str, **kwargs) -> Host:
114        """
115        更新主机配置
116
117        Args:
118            name: 主机名
119            **kwargs: 要更新的字段
120
121        Returns:
122            Host: 更新后的主机
123        """
124        host = self._repo.get(name)
125        for key, value in kwargs.items():
126            if hasattr(host, key):
127                setattr(host, key, value)
128
129        # 如果密码被更新,重新加密
130        if (
131            "password" in kwargs
132            and kwargs["password"] is not None
133            and not self._encryption.is_encrypted(kwargs["password"])
134        ):
135            host.password = self._encryption.encrypt(kwargs["password"])
136
137        self._repo.save(host)
138        self._repo.flush()
139        logger.info(f"host updated: {name}")
140        return host

更新主机配置

Args: name: 主机名 **kwargs: 要更新的字段

Returns: Host: 更新后的主机

def remove_host(self, name: str) -> None:
142    def remove_host(self, name: str) -> None:
143        """删除主机"""
144        self._repo.delete(name)
145        self._repo.flush()
146        logger.info(f"host removed: {name}")

删除主机

def list_hosts(self, tag: str | None = None) -> list[Host]:
148    def list_hosts(self, tag: Optional[str] = None) -> list[Host]:
149        """列出主机(密码自动解密)"""
150        hosts = self._repo.list(tag=tag)
151        return [self._decrypt_host(h) for h in hosts]

列出主机(密码自动解密)

def list_tags(self) -> list[str]:
153    def list_tags(self) -> list[str]:
154        """列出所有标签"""
155        return self._repo.list_tags()

列出所有标签

def connect_to_host(self, name: str) -> SSHClient:
161    def connect_to_host(self, name: str) -> SSHClient:
162        """
163        建立到主机的 SSH 连接
164
165        Args:
166            name: 主机名
167
168        Returns:
169            SSHClient: 已连接的客户端
170        """
171        host = self.resolve_host(name)
172        return self._ssh.create_client(**self._to_ssh_args(host))

建立到主机的 SSH 连接

Args: name: 主机名

Returns: SSHClient: 已连接的客户端

def test_connection(self, name: str) -> bool:
174    def test_connection(self, name: str) -> bool:
175        """
176        测试主机连接
177
178        Args:
179            name: 主机名
180
181        Returns:
182            bool: True if connected
183        """
184        host = self.resolve_host(name)
185        return self._ssh.test_connection(**self._to_ssh_args(host))

测试主机连接

Args: name: 主机名

Returns: bool: True if connected

def test_all_connections(self, max_workers: int = 10) -> dict[str, bool]:
187    def test_all_connections(self, max_workers: int = 10) -> dict[str, bool]:
188        """并行测试所有主机连接"""
189        from concurrent.futures import ThreadPoolExecutor, as_completed
190
191        hosts = self._repo.list()
192        results: dict[str, bool] = {}
193
194        with ThreadPoolExecutor(max_workers=max_workers) as executor:
195            future_map = {executor.submit(self.test_connection, h.name): h.name for h in hosts}
196            for future in as_completed(future_map):
197                name = future_map[future]
198                try:
199                    results[name] = future.result()
200                except Exception as e:  # noqa: BLE001
201                    logger.error(f"connection test error for host {name}: {e}")
202                    results[name] = False
203
204        return results

并行测试所有主机连接

def resolve_host(self, name: str) -> Host:
255    def resolve_host(self, name: str) -> Host:
256        """
257        获取主机并尝试解密密码
258
259        解密优先级:
260        1. 通过凭据提供链(环境变量 / keyring / 加密文件存储等)获取明文
261        2. 若凭据链未命中,则回退到本地 CredentialEncryption 解密存储中的加密 token
262
263        这一层兜底是必需的:CLI 的默认凭据链可能不包含
264        EncryptedFileCredentialProvider,但主机密码已被 add_host 加密落盘,
265        若不兜底解密则 connect_to_host 会拿到加密 token 当密码使用,必然认证失败。
266
267        Note:
268            本方法返回一个新的 Host 副本,绝不就地修改仓库内存中存储的对象。
269            若直接修改 repo.get() 返回的原始引用,解密后的明文密码会污染内存
270            中的加密 token,后续任意 add_host/update_host/remove_host 触发
271            flush() 时明文密码会被写入磁盘,造成凭据泄露。
272        """
273        host = self._repo.get(name)
274
275        # 解析密码:始终写入新变量,不修改 host 原对象
276        resolved_password = host.password
277        if host.password and self._encryption.is_encrypted(host.password):
278            resolved = self._cred_provider.get_password(host)
279            if resolved:
280                resolved_password = resolved
281            else:
282                # 凭据链未命中,回退到本地加密器解密
283                try:
284                    resolved_password = self._encryption.decrypt(host.password)
285                except Exception as e:  # noqa: BLE001
286                    logger.warning(f"failed to decrypt password for {host.name}: {e}")
287                    # 保留加密 token,留给 SSH 层报认证失败
288
289        # 解析密钥路径
290        resolved_key_filename = host.key_filename
291        if resolved_key_filename:
292            resolved_key_filename = str(Path(resolved_key_filename).expanduser())
293
294        # 返回新对象,保持仓库内存中的加密 token 不被污染
295        return self._build_resolved_host(host, resolved_password, resolved_key_filename)

获取主机并尝试解密密码

解密优先级:

  1. 通过凭据提供链(环境变量 / keyring / 加密文件存储等)获取明文
  2. 若凭据链未命中,则回退到本地 CredentialEncryption 解密存储中的加密 token

这一层兜底是必需的:CLI 的默认凭据链可能不包含 EncryptedFileCredentialProvider,但主机密码已被 add_host 加密落盘, 若不兜底解密则 connect_to_host 会拿到加密 token 当密码使用,必然认证失败。

Note: 本方法返回一个新的 Host 副本,绝不就地修改仓库内存中存储的对象。 若直接修改 repo.get() 返回的原始引用,解密后的明文密码会污染内存 中的加密 token,后续任意 add_host/update_host/remove_host 触发 flush() 时明文密码会被写入磁盘,造成凭据泄露。

class SSHService:
 21class SSHService:
 22    """
 23    SSH 连接服务
 24
 25    提供连接管理、命令执行和健康检查功能。
 26    支持重试和超时控制。
 27    """
 28
 29    def __init__(self, timeout: int = 30) -> None:
 30        self._timeout = timeout
 31
 32    def create_client(
 33        self,
 34        hostname: str,
 35        username: str,
 36        port: int = 22,
 37        password: Optional[str] = None,
 38        key_filename: Optional[str] = None,
 39        known_hosts_file: Optional[str] = None,
 40    ) -> SSHClient:
 41        """
 42        创建并建立 SSH 连接
 43
 44        Returns:
 45            已连接的 SSHClient 实例
 46
 47        Raises:
 48            SSHConnectionError: 连接失败
 49        """
 50        config = ConnectionConfig(
 51            hostname=hostname,
 52            username=username,
 53            port=port,
 54            password=password,
 55            key_filename=key_filename,
 56            timeout=self._timeout,
 57            known_hosts_file=known_hosts_file,
 58        )
 59        client = SSHClient(config)
 60        return client.connect()
 61
 62    def test_connection(
 63        self,
 64        hostname: str,
 65        username: str,
 66        port: int = 22,
 67        password: Optional[str] = None,
 68        key_filename: Optional[str] = None,
 69    ) -> bool:
 70        """
 71        测试主机连接是否正常
 72
 73        Returns:
 74            bool: True if connected
 75        """
 76        try:
 77            with self.create_client(
 78                hostname=hostname,
 79                username=username,
 80                port=port,
 81                password=password,
 82                key_filename=key_filename,
 83            ) as client:
 84                return client.is_connected()
 85        except Exception as e:  # noqa: BLE001
 86            logger.debug(f"connection test failed {hostname}:{port}: {e}")
 87            return False
 88
 89    def execute_command(
 90        self,
 91        hostname: str,
 92        username: str,
 93        command: str,
 94        port: int = 22,
 95        password: Optional[str] = None,
 96        key_filename: Optional[str] = None,
 97        timeout: Optional[int] = None,
 98    ) -> CommandResult:
 99        """
100        在远程主机上执行命令
101
102        Returns:
103            CommandResult: 命令执行结果
104        """
105        with self.create_client(
106            hostname=hostname,
107            username=username,
108            port=port,
109            password=password,
110            key_filename=key_filename,
111        ) as client:
112            return client.execute(command, timeout=timeout)

SSH 连接服务

提供连接管理、命令执行和健康检查功能。 支持重试和超时控制。

SSHService(timeout: int = 30)
29    def __init__(self, timeout: int = 30) -> None:
30        self._timeout = timeout
def create_client( self, hostname: str, username: str, port: int = 22, password: str | None = None, key_filename: str | None = None, known_hosts_file: str | None = None) -> SSHClient:
32    def create_client(
33        self,
34        hostname: str,
35        username: str,
36        port: int = 22,
37        password: Optional[str] = None,
38        key_filename: Optional[str] = None,
39        known_hosts_file: Optional[str] = None,
40    ) -> SSHClient:
41        """
42        创建并建立 SSH 连接
43
44        Returns:
45            已连接的 SSHClient 实例
46
47        Raises:
48            SSHConnectionError: 连接失败
49        """
50        config = ConnectionConfig(
51            hostname=hostname,
52            username=username,
53            port=port,
54            password=password,
55            key_filename=key_filename,
56            timeout=self._timeout,
57            known_hosts_file=known_hosts_file,
58        )
59        client = SSHClient(config)
60        return client.connect()

创建并建立 SSH 连接

Returns: 已连接的 SSHClient 实例

Raises: SSHConnectionError: 连接失败

def test_connection( self, hostname: str, username: str, port: int = 22, password: str | None = None, key_filename: str | None = None) -> bool:
62    def test_connection(
63        self,
64        hostname: str,
65        username: str,
66        port: int = 22,
67        password: Optional[str] = None,
68        key_filename: Optional[str] = None,
69    ) -> bool:
70        """
71        测试主机连接是否正常
72
73        Returns:
74            bool: True if connected
75        """
76        try:
77            with self.create_client(
78                hostname=hostname,
79                username=username,
80                port=port,
81                password=password,
82                key_filename=key_filename,
83            ) as client:
84                return client.is_connected()
85        except Exception as e:  # noqa: BLE001
86            logger.debug(f"connection test failed {hostname}:{port}: {e}")
87            return False

测试主机连接是否正常

Returns: bool: True if connected

def execute_command( self, hostname: str, username: str, command: str, port: int = 22, password: str | None = None, key_filename: str | None = None, timeout: int | None = None) -> remote_cmd.core.ssh_client.CommandResult:
 89    def execute_command(
 90        self,
 91        hostname: str,
 92        username: str,
 93        command: str,
 94        port: int = 22,
 95        password: Optional[str] = None,
 96        key_filename: Optional[str] = None,
 97        timeout: Optional[int] = None,
 98    ) -> CommandResult:
 99        """
100        在远程主机上执行命令
101
102        Returns:
103            CommandResult: 命令执行结果
104        """
105        with self.create_client(
106            hostname=hostname,
107            username=username,
108            port=port,
109            password=password,
110            key_filename=key_filename,
111        ) as client:
112            return client.execute(command, timeout=timeout)

在远程主机上执行命令

Returns: CommandResult: 命令执行结果

class CredentialProvider(abc.ABC):
36class CredentialProvider(ABC):
37    """凭据提供者抽象基类"""
38
39    @abstractmethod
40    def get_password(self, host: Host) -> Optional[str]:
41        """get a host by name, or None if not found"""
42        ...

凭据提供者抽象基类

@abstractmethod
def get_password(self, host: Host) -> str | None:
39    @abstractmethod
40    def get_password(self, host: Host) -> Optional[str]:
41        """get a host by name, or None if not found"""
42        ...

get a host by name, or None if not found

class EnvCredentialProvider(remote_cmd.CredentialProvider):
45class EnvCredentialProvider(CredentialProvider):
46    """
47    从环境变量获取密码
48
49    适用于 CI/CD 或容器环境。
50    优先级低于交互式输入但高于默认值。
51
52    支持两种查找方式(按优先级):
53    1. 主机专属变量 ``<env_var>_<HOST>``(主机名大写,非字母数字替换为 ``_``)
54    2. 全局变量 ``<env_var>``
55
56    示例:
57        - 全局:``REMOTE_CMD_PASSWORD=secret`` 对所有主机生效
58        - 专属:``REMOTE_CMD_PASSWORD_WEB1=secret`` 仅对名为 ``web1`` 的主机生效
59
60    Args:
61        env_var: 环境变量名(默认 REMOTE_CMD_PASSWORD)
62    """
63
64    def __init__(self, env_var: str = "REMOTE_CMD_PASSWORD") -> None:
65        self._env_var = env_var
66
67    @staticmethod
68    def _host_env_suffix(host_name: str) -> str:
69        """
70        将主机名转换为环境变量后缀:web1 -> WEB1,my-host -> MY_HOST
71
72        注意: 非字母数字字符统一归一化为下划线,因此 ``web-1`` 与 ``web_1``
73        会映射到同一个变量 ``..._WEB_1``。若同舰队同时存在这两种命名,
74        需避免依赖同名变量(如需区分请先统一主机命名规范)。
75        """
76        normalized = "".join(c if c.isalnum() else "_" for c in host_name).upper()
77        return normalized
78
79    def get_password(self, host: Host) -> Optional[str]:
80        # 优先主机专属变量,避免全局变量被应用到所有主机
81        if host and host.name:
82            host_var = f"{self._env_var}_{self._host_env_suffix(host.name)}"
83            host_password = os.environ.get(host_var)
84            if host_password is not None:
85                return host_password
86        return os.environ.get(self._env_var)

从环境变量获取密码

适用于 CI/CD 或容器环境。 优先级低于交互式输入但高于默认值。

支持两种查找方式(按优先级):

  1. 主机专属变量 <env_var>_<HOST>(主机名大写,非字母数字替换为 _
  2. 全局变量 <env_var>

示例: - 全局:REMOTE_CMD_PASSWORD=secret 对所有主机生效 - 专属:REMOTE_CMD_PASSWORD_WEB1=secret 仅对名为 web1 的主机生效

Args: env_var: 环境变量名(默认 REMOTE_CMD_PASSWORD)

EnvCredentialProvider(env_var: str = 'REMOTE_CMD_PASSWORD')
64    def __init__(self, env_var: str = "REMOTE_CMD_PASSWORD") -> None:
65        self._env_var = env_var
def get_password(self, host: Host) -> str | None:
79    def get_password(self, host: Host) -> Optional[str]:
80        # 优先主机专属变量,避免全局变量被应用到所有主机
81        if host and host.name:
82            host_var = f"{self._env_var}_{self._host_env_suffix(host.name)}"
83            host_password = os.environ.get(host_var)
84            if host_password is not None:
85                return host_password
86        return os.environ.get(self._env_var)

get a host by name, or None if not found

class ChainCredentialProvider(remote_cmd.CredentialProvider):
116class ChainCredentialProvider(CredentialProvider):
117    """
118    链式凭据提供者
119
120    按顺序尝试每个提供者,返回第一个非空结果。
121    适用于 "环境变量 → 加密文件 → 交互式输入" 的优先级链。
122
123    Args:
124        providers: 凭据提供者列表,按优先级降序排列
125    """
126
127    def __init__(self, providers: list[CredentialProvider]) -> None:
128        self._providers = list(providers)
129
130    def get_password(self, host: Host) -> Optional[str]:
131        for provider in self._providers:
132            password = provider.get_password(host)
133            if password is not None:
134                return password
135        return None
136
137    def add_provider(self, provider: CredentialProvider) -> None:
138        """在链尾添加一个提供者"""
139        self._providers.append(provider)

链式凭据提供者

按顺序尝试每个提供者,返回第一个非空结果。 适用于 "环境变量 → 加密文件 → 交互式输入" 的优先级链。

Args: providers: 凭据提供者列表,按优先级降序排列

ChainCredentialProvider( providers: list[CredentialProvider])
127    def __init__(self, providers: list[CredentialProvider]) -> None:
128        self._providers = list(providers)
def get_password(self, host: Host) -> str | None:
130    def get_password(self, host: Host) -> Optional[str]:
131        for provider in self._providers:
132            password = provider.get_password(host)
133            if password is not None:
134                return password
135        return None

get a host by name, or None if not found

def add_provider( self, provider: CredentialProvider) -> None:
137    def add_provider(self, provider: CredentialProvider) -> None:
138        """在链尾添加一个提供者"""
139        self._providers.append(provider)

在链尾添加一个提供者

class CredentialEncryption:
 48class CredentialEncryption:
 49    """
 50    Fernet 凭据加解密器
 51
 52    使用 cryptography.fernet 实现对称加密。
 53    密钥自动管理:首次加密时生成,后续自动加载。
 54
 55    Attributes:
 56        key_path: 密钥文件存储路径
 57    """
 58
 59    _PREFIX = "$encrypted$"
 60
 61    def __init__(self, key_path: Optional[Path] = None) -> None:
 62        """
 63        Args:
 64            key_path: 密钥文件路径,默认 ~/.remote_cmd/.key
 65        """
 66        self._key_path = key_path or (Path.home() / ".remote_cmd" / ".key")
 67        self._fernet: Optional[Fernet] = None
 68
 69    @property
 70    def _cipher(self) -> "Fernet":
 71        """延迟初始化 Fernet 实例"""
 72        if self._fernet is None:
 73            key = self._load_or_create_key()
 74            from cryptography.fernet import Fernet
 75
 76            self._fernet = Fernet(key)
 77        return self._fernet
 78
 79    def encrypt(self, plaintext: str) -> str:
 80        """
 81        加密明文密码
 82
 83        Args:
 84            plaintext: 明文密码
 85
 86        Returns:
 87            str: Base64 编码的密文字符串(格式: $encrypted$<token>)
 88
 89        Raises:
 90            CredentialEncryptionError: 加密失败
 91        """
 92        try:
 93            token = self._cipher.encrypt(plaintext.encode("utf-8"))
 94            return self._PREFIX + token.decode("utf-8")
 95        except Exception as e:
 96            raise CredentialEncryptionError(f"encryption failed: {e}") from e
 97
 98    def decrypt(self, ciphertext: str) -> str:
 99        """
100        解密密文
101
102        Args:
103            ciphertext: 加密后的密文字符串
104
105        Returns:
106            str: 明文密码
107
108        Raises:
109            CredentialEncryptionError: 解密失败或格式错误
110        """
111        if not ciphertext.startswith(self._PREFIX):
112            raise CredentialEncryptionError("invalid ciphertext format")
113
114        try:
115            token = ciphertext[len(self._PREFIX) :].encode("utf-8")
116            return self._cipher.decrypt(token).decode("utf-8")
117        except Exception as e:
118            raise CredentialEncryptionError(f"decryption failed: {e}") from e
119
120    def is_encrypted(self, value: str) -> bool:
121        """
122        检查字符串是否为加密格式
123
124        采用双重校验降低格式碰撞风险:
125        1. 前缀匹配 ``$encrypted$``
126        2. token 部分必须是 Fernet 格式(version byte 0x80,base64 编码后以 ``g`` 开头,
127           且解码后总长度至少 57 字节 = 1 + 8 + 16 + 32)
128
129        这避免了与真实密码恰好以 ``$encrypted$`` 开头时被误判的场景:
130        真实密码很少以 ``$encrypted$g`` 开头,且 base64 解码长度必须 >= 57 字节。
131
132        Attention: 本函数不验证 HMAC/签名,仅为廉价标识符检测;
133        完整合法性校验在 :meth:`decrypt` 中完成。
134        """
135        if not value.startswith(self._PREFIX):
136            return False
137        token = value[len(self._PREFIX) :]
138        # Fernet token base64 解码后第一字节恒为 0x80(编码后开头为 "g")
139        if not token.startswith("g"):
140            return False
141        # 重新填充 base64 并解码长度校验,避免误判纯字符串
142        import base64
143
144        padding = "=" * (-len(token) % 4)
145        try:
146            decoded = base64.urlsafe_b64decode(token + padding)
147        except ValueError:
148            return False
149        # 最小密文长度 = version(1) + timestamp(8) + IV(16) + HMAC(32)
150        return len(decoded) >= 57
151
152    def _load_or_create_key(self) -> bytes:
153        """
154        加载现有密钥或生成新密钥
155
156        密钥文件权限设为 0600,防止其他用户读取。
157        校验密钥格式:必须是合法的 Fernet key(32 字节 base64 编码)。
158        """
159        from cryptography.fernet import Fernet
160
161        if self._key_path.exists():
162            # 确保现有密钥文件权限正确
163            try:
164                current_perms = stat_module.S_IMODE(self._key_path.stat().st_mode)
165                if current_perms != 0o600:
166                    self._key_path.chmod(0o600)
167            except OSError:
168                pass  # Windows 或权限不足时忽略
169
170            key = self._key_path.read_bytes()
171            # 校验密钥格式:Fernet key 必须是 32 字节 base64 编码(44 字符,含 padding)
172            try:
173                decoded = base64.urlsafe_b64decode(key)
174                if len(decoded) != 32:
175                    raise ValueError(f"Invalid key length: {len(decoded)} bytes, expected 32")
176            except (ValueError, binascii.Error) as e:
177                logger.error(f"Invalid encryption key format at {self._key_path}: {e}")
178                raise CredentialEncryptionError(
179                    f"Corrupted or invalid key file: {self._key_path}. "
180                    f"Delete it to regenerate, or check permissions."
181                ) from e
182
183            return key
184
185        # 生成新密钥
186        key = Fernet.generate_key()
187        self._key_path.parent.mkdir(parents=True, exist_ok=True)
188        # 安全:原子创建密钥文件并直接设置 0600 权限,避免 write_bytes(受 umask
189        # 影响,默认可能为 0644)与 chmod(0600) 之间的 TOCTOU 窗口——在此窗口内
190        # 同机其他用户可读取主加密密钥,进而解密所有凭据。
191        # O_EXCL 同时消除多进程并发首次创建时互相覆盖密钥的竞态。
192        try:
193            fd = os.open(
194                str(self._key_path),
195                os.O_WRONLY | os.O_CREAT | os.O_EXCL,
196                0o600,
197            )
198        except FileExistsError:
199            # 并发首次创建:另一进程已生成密钥,重新加载
200            return self._load_or_create_key()
201        try:
202            os.write(fd, key)
203        finally:
204            os.close(fd)
205
206        logger.info(f"generated encryption key: {self._key_path}")
207        return key

Fernet 凭据加解密器

使用 cryptography.fernet 实现对称加密。 密钥自动管理:首次加密时生成,后续自动加载。

Attributes: key_path: 密钥文件存储路径

CredentialEncryption(key_path: pathlib.Path | None = None)
61    def __init__(self, key_path: Optional[Path] = None) -> None:
62        """
63        Args:
64            key_path: 密钥文件路径,默认 ~/.remote_cmd/.key
65        """
66        self._key_path = key_path or (Path.home() / ".remote_cmd" / ".key")
67        self._fernet: Optional[Fernet] = None

Args: key_path: 密钥文件路径,默认 ~/.remote_cmd/.key

def encrypt(self, plaintext: str) -> str:
79    def encrypt(self, plaintext: str) -> str:
80        """
81        加密明文密码
82
83        Args:
84            plaintext: 明文密码
85
86        Returns:
87            str: Base64 编码的密文字符串(格式: $encrypted$<token>)
88
89        Raises:
90            CredentialEncryptionError: 加密失败
91        """
92        try:
93            token = self._cipher.encrypt(plaintext.encode("utf-8"))
94            return self._PREFIX + token.decode("utf-8")
95        except Exception as e:
96            raise CredentialEncryptionError(f"encryption failed: {e}") from e

加密明文密码

Args: plaintext: 明文密码

Returns: str: Base64 编码的密文字符串(格式: $encrypted$

Raises: CredentialEncryptionError: 加密失败

def decrypt(self, ciphertext: str) -> str:
 98    def decrypt(self, ciphertext: str) -> str:
 99        """
100        解密密文
101
102        Args:
103            ciphertext: 加密后的密文字符串
104
105        Returns:
106            str: 明文密码
107
108        Raises:
109            CredentialEncryptionError: 解密失败或格式错误
110        """
111        if not ciphertext.startswith(self._PREFIX):
112            raise CredentialEncryptionError("invalid ciphertext format")
113
114        try:
115            token = ciphertext[len(self._PREFIX) :].encode("utf-8")
116            return self._cipher.decrypt(token).decode("utf-8")
117        except Exception as e:
118            raise CredentialEncryptionError(f"decryption failed: {e}") from e

解密密文

Args: ciphertext: 加密后的密文字符串

Returns: str: 明文密码

Raises: CredentialEncryptionError: 解密失败或格式错误

def is_encrypted(self, value: str) -> bool:
120    def is_encrypted(self, value: str) -> bool:
121        """
122        检查字符串是否为加密格式
123
124        采用双重校验降低格式碰撞风险:
125        1. 前缀匹配 ``$encrypted$``
126        2. token 部分必须是 Fernet 格式(version byte 0x80,base64 编码后以 ``g`` 开头,
127           且解码后总长度至少 57 字节 = 1 + 8 + 16 + 32)
128
129        这避免了与真实密码恰好以 ``$encrypted$`` 开头时被误判的场景:
130        真实密码很少以 ``$encrypted$g`` 开头,且 base64 解码长度必须 >= 57 字节。
131
132        Attention: 本函数不验证 HMAC/签名,仅为廉价标识符检测;
133        完整合法性校验在 :meth:`decrypt` 中完成。
134        """
135        if not value.startswith(self._PREFIX):
136            return False
137        token = value[len(self._PREFIX) :]
138        # Fernet token base64 解码后第一字节恒为 0x80(编码后开头为 "g")
139        if not token.startswith("g"):
140            return False
141        # 重新填充 base64 并解码长度校验,避免误判纯字符串
142        import base64
143
144        padding = "=" * (-len(token) % 4)
145        try:
146            decoded = base64.urlsafe_b64decode(token + padding)
147        except ValueError:
148            return False
149        # 最小密文长度 = version(1) + timestamp(8) + IV(16) + HMAC(32)
150        return len(decoded) >= 57

检查字符串是否为加密格式

采用双重校验降低格式碰撞风险:

  1. 前缀匹配 $encrypted$
  2. token 部分必须是 Fernet 格式(version byte 0x80,base64 编码后以 g 开头, 且解码后总长度至少 57 字节 = 1 + 8 + 16 + 32)

这避免了与真实密码恰好以 $encrypted$ 开头时被误判的场景: 真实密码很少以 $encrypted$g 开头,且 base64 解码长度必须 >= 57 字节。

Attention: 本函数不验证 HMAC/签名,仅为廉价标识符检测; 完整合法性校验在 decrypt() 中完成。

def setup_logging( level: str = 'INFO', log_file: str | None = None, max_bytes: int = 10485760, backup_count: int = 5, structured: bool = False, verbose: bool = False) -> None:
101def setup_logging(
102    level: str = "INFO",
103    log_file: Optional[str] = None,
104    max_bytes: int = 10 * 1024 * 1024,  # 10MB
105    backup_count: int = 5,
106    structured: bool = False,
107    verbose: bool = False,
108) -> None:
109    """
110    配置统一日志系统
111
112    Args:
113        level: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
114        log_file: 日志文件路径(可选,不指定时只输出到控制台)
115        max_bytes: 单个日志文件最大字节数
116        backup_count: 保留的轮转文件数
117        structured: 是否使用 JSON 结构化格式
118        verbose: 是否启用详细格式
119    """
120    root_logger = logging.getLogger()
121    root_logger.setLevel(getattr(logging, level.upper(), logging.INFO))
122
123    # 清除已有处理器
124    root_logger.handlers.clear()
125
126    # 创建格式化器
127    if structured:
128        formatter = logging.Formatter(STRUCTURED_FORMAT)
129    elif verbose:
130        formatter = logging.Formatter(VERBOSE_FORMAT)
131    else:
132        formatter = logging.Formatter(DEFAULT_FORMAT)
133
134    # 添加敏感数据过滤器
135    sensitive_filter = SensitiveDataFilter()
136
137    # 控制台输出
138    console_handler = logging.StreamHandler(sys.stderr)
139    console_handler.setFormatter(formatter)
140    console_handler.addFilter(sensitive_filter)
141    root_logger.addHandler(console_handler)
142
143    # 文件输出(可选)
144    if log_file:
145        log_path = Path(log_file)
146        log_path.parent.mkdir(parents=True, exist_ok=True)
147
148        file_handler = RotatingFileHandler(
149            str(log_path),
150            maxBytes=max_bytes,
151            backupCount=backup_count,
152            encoding="utf-8",
153        )
154        file_handler.setFormatter(formatter)
155        file_handler.addFilter(sensitive_filter)
156        root_logger.addHandler(file_handler)
157
158    # 配置 remote_cmd 包日志
159    logging.getLogger("remote_cmd").setLevel(getattr(logging, level.upper(), logging.INFO))
160
161    logging.getLogger("remote_cmd").debug("日志系统已初始化")

配置统一日志系统

Args: level: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL) log_file: 日志文件路径(可选,不指定时只输出到控制台) max_bytes: 单个日志文件最大字节数 backup_count: 保留的轮转文件数 structured: 是否使用 JSON 结构化格式 verbose: 是否启用详细格式

class SensitiveDataFilter(logging.Filter):
51class SensitiveDataFilter(logging.Filter):
52    """
53    日志过滤器:自动脱敏敏感数据
54
55    重写 filter 方法,在日志记录发出前替换敏感字段。
56    """
57
58    def filter(self, record: logging.LogRecord) -> bool:
59        if isinstance(record.msg, str):
60            record.msg = redact_sensitive_data(record.msg)
61        if record.args:
62            # 处理 %s 格式的参数
63            cleaned_args = tuple(
64                redact_sensitive_data(str(a)) if isinstance(a, str) else a for a in record.args
65            )
66            record.args = cleaned_args
67        return True
68
69    @staticmethod
70    def redact_dict(data: dict[str, Any]) -> dict[str, Any]:
71        """递归脱敏字典中的敏感字段"""
72        result: dict[str, Any] = {}
73        for key, value in data.items():
74            if key.lower() in SENSITIVE_FIELDS:
75                result[key] = "[REDACTED]"
76            elif isinstance(value, dict):
77                result[key] = SensitiveDataFilter.redact_dict(value)
78            else:
79                result[key] = value
80        return result

日志过滤器:自动脱敏敏感数据

重写 filter 方法,在日志记录发出前替换敏感字段。

def filter(self, record: logging.LogRecord) -> bool:
58    def filter(self, record: logging.LogRecord) -> bool:
59        if isinstance(record.msg, str):
60            record.msg = redact_sensitive_data(record.msg)
61        if record.args:
62            # 处理 %s 格式的参数
63            cleaned_args = tuple(
64                redact_sensitive_data(str(a)) if isinstance(a, str) else a for a in record.args
65            )
66            record.args = cleaned_args
67        return True

Determine if the specified record is to be logged.

Returns True if the record should be logged, or False otherwise. If deemed appropriate, the record may be modified in-place.

@staticmethod
def redact_dict(data: dict[str, typing.Any]) -> dict[str, typing.Any]:
69    @staticmethod
70    def redact_dict(data: dict[str, Any]) -> dict[str, Any]:
71        """递归脱敏字典中的敏感字段"""
72        result: dict[str, Any] = {}
73        for key, value in data.items():
74            if key.lower() in SENSITIVE_FIELDS:
75                result[key] = "[REDACTED]"
76            elif isinstance(value, dict):
77                result[key] = SensitiveDataFilter.redact_dict(value)
78            else:
79                result[key] = value
80        return result

递归脱敏字典中的敏感字段

def get_logger(name: str, **context) -> 'logging.Logger | logging.LoggerAdapter':
187def get_logger(name: str, **context) -> "logging.Logger | logging.LoggerAdapter":
188    """
189    获取带可选上下文的日志器
190
191    用法:
192        >>> log = get_logger(__name__, host="web-server")
193        >>> log.info("测试连接")
194    """
195    logger = logging.getLogger(name)
196    if context:
197        return LoggerAdapter(logger, context)
198    return logger

获取带可选上下文的日志器

用法:

log = get_logger(__name__, host="web-server") log.info("测试连接")

class SqliteHostRepository(remote_cmd.HostRepository):
 69class SqliteHostRepository(HostRepository):
 70    """
 71    SQLite 主机仓库
 72
 73    Args:
 74        db_path: SQLite 数据库文件路径
 75        migrate_from: JSON 文件路径,用于自动迁移(仅首次使用)
 76        auto_create: 是否自动创建表和数据库,默认 True
 77        encryption: 可选的凭据加密器(设置后 save() 自动加密 password)
 78
 79    注意: 密码的加密依赖传入 encryption。若直接以明文密码调用 save()
 80    且未提供 encryption,明文会被持久化到数据库。请勿绕过 HostService。
 81    """
 82
 83    def __init__(
 84        self,
 85        db_path: str,
 86        migrate_from: Optional[str] = None,
 87        auto_create: bool = True,
 88        encryption: Optional[CredentialEncryption] = None,
 89    ) -> None:
 90        self._db_path = db_path
 91        self._lock = threading.Lock()
 92        self._encryption = encryption
 93        self._guard = PasswordGuard(encryption)
 94
 95        if auto_create:
 96            self._init_db()
 97
 98        if migrate_from:
 99            self._maybe_migrate_from_json(migrate_from)
100
101    # ========================================================================
102    # 数据库初始化
103    # ========================================================================
104
105    def _init_db(self) -> None:
106        """初始化数据库:创建表和索引"""
107        with self._txn() as conn:
108            conn.execute(CREATE_TABLE_SQL)
109            conn.execute(CREATE_META_SQL)
110            for idx_sql in CREATE_INDEXES_SQL:
111                conn.execute(idx_sql)
112            # 设置数据库版本
113            conn.execute(
114                "INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)",
115                ("db_version", str(DB_VERSION)),
116            )
117            conn.commit()
118        logger.debug(f"SQLite database initialized: {self._db_path}")
119
120    def _get_conn(self) -> sqlite3.Connection:
121        """获取数据库连接(线程安全)"""
122        conn = sqlite3.connect(self._db_path, check_same_thread=False)
123        conn.row_factory = sqlite3.Row
124        conn.execute("PRAGMA journal_mode=WAL;")
125        conn.execute("PRAGMA foreign_keys=ON;")
126        return conn
127
128    @contextlib.contextmanager
129    def _txn(self):
130        """
131        事务 + 连接生命周期上下文
132
133        包装 ``with conn:`` 与 ``conn.close()`` 为单一上下文:
134        - 进入时打开新连接并执行 PRAGMA
135        - 退出时先 ``conn.__exit__`` 提交/回滚,再 ``conn.close()`` 释放 fd
136
137        解决 ``with self._get_conn() as conn:`` 不自动 close 导致的 fd 累积泄漏
138        (sqlite3.Connection.__exit__ 仅管理事务边界,不释放连接句柄)。
139
140        所有读写操作都应通过 ``with self._lock, self._txn() as conn:`` 使用,
141        保证 ``self._lock`` 串行化的同时每次操作后释放 fd。
142        """
143        conn = self._get_conn()
144        try:
145            with conn:  # 事务:commit 或 rollback
146                yield conn
147        finally:
148            conn.close()
149
150    # ========================================================================
151    # JSON 迁移
152    # ========================================================================
153
154    def _maybe_migrate_from_json(self, json_path: str) -> None:
155        """
156        if database is empty and JSON file exists, run migration
157
158        Args:
159            json_path: JSON 文件路径
160        """
161        with self._lock, self._txn() as conn:
162            count = conn.execute("SELECT COUNT(*) as cnt FROM hosts").fetchone()["cnt"]
163            if count > 0:
164                logger.info("database not empty, skipping JSON migration")
165                return
166
167        # 尝试加载 JSON 文件
168        try:
169            from pathlib import Path
170
171            path = Path(json_path)
172            if not path.exists():
173                logger.info(f"JSON file not found, skipping migration: {json_path}")
174                return
175
176            with open(path, encoding="utf-8") as f:
177                raw_data = json.load(f)
178
179            # 解析版本格式
180            version = raw_data.get("version", 1)
181            hosts_data = raw_data.get("hosts", raw_data if version == 1 else {})
182
183            if not isinstance(hosts_data, dict):
184                logger.warning(f"unrecognized JSON format: {json_path}")
185                return
186
187            imported = 0
188            for name, host_dict in hosts_data.items():
189                try:
190                    host = Host.from_dict(host_dict)
191                    self.save(host)
192                    imported += 1
193                except (ValueError, TypeError, KeyError) as e:
194                    logger.warning(f"skipping invalid host '{name}': {e}")
195
196            if imported > 0:
197                logger.info(f"migrated {imported} hosts to SQLite")
198
199        except (OSError, json.JSONDecodeError, ValueError) as e:
200            logger.warning(f"JSON migration failed: {e}")
201
202    # ========================================================================
203    # Repository 接口实现
204    # ========================================================================
205
206    def save(self, host: Host) -> None:
207        """保存或更新主机"""
208        with self._lock, self._txn() as conn:
209            tags_json = json.dumps(host.tags or [], ensure_ascii=False)
210            # 配置了加密器时,明文密码先加密再落库
211            password = self._guard.encrypt(host.password)
212            conn.execute(
213                """
214                    INSERT INTO hosts (name, hostname, username, port, password,
215                                       key_filename, tags, description, updated_at)
216                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
217                    ON CONFLICT(name) DO UPDATE SET
218                        hostname = excluded.hostname,
219                        username = excluded.username,
220                        port = excluded.port,
221                        password = excluded.password,
222                        key_filename = excluded.key_filename,
223                        tags = excluded.tags,
224                        description = excluded.description,
225                        updated_at = CURRENT_TIMESTAMP
226                    """,
227                (
228                    host.name,
229                    host.hostname,
230                    host.username,
231                    host.port,
232                    password,
233                    host.key_filename,
234                    tags_json,
235                    host.description,
236                ),
237            )
238            conn.commit()
239
240    def get(self, name: str) -> Host:
241        """按名称获取主机"""
242        with self._lock, self._txn() as conn:
243            row = conn.execute("SELECT * FROM hosts WHERE name = ?", (name,)).fetchone()
244
245        if row is None:
246            raise KeyError(f"Host '{name}' not found")
247
248        return self._row_to_host(row)
249
250    def delete(self, name: str) -> None:
251        """按名称删除主机"""
252        with self._lock, self._txn() as conn:
253            cursor = conn.execute("DELETE FROM hosts WHERE name = ?", (name,))
254            conn.commit()
255
256        if cursor.rowcount == 0:
257            raise KeyError(f"Host '{name}' not found")
258
259    def list(self, tag: Optional[str] = None) -> list[Host]:
260        """列出主机,可选按标签筛选"""
261        with self._lock, self._txn() as conn:
262            if tag:
263                # 使用 LIKE 匹配 tags JSON 中的标签
264                rows = conn.execute(
265                    "SELECT * FROM hosts WHERE tags LIKE ? ORDER BY name",
266                    (f'%"{tag}"%',),
267                ).fetchall()
268            else:
269                rows = conn.execute("SELECT * FROM hosts ORDER BY name").fetchall()
270
271        return [self._row_to_host(row) for row in rows]
272
273    def list_tags(self) -> builtins.list[str]:
274        """列出所有标签"""
275        with self._lock, self._txn() as conn:
276            rows = conn.execute("SELECT DISTINCT tags FROM hosts WHERE tags IS NOT NULL").fetchall()
277
278        tags_set: set = set()
279        for row in rows:
280            try:
281                tags = json.loads(row["tags"] or "[]")
282                if isinstance(tags, list):
283                    tags_set.update(tags)
284            except (json.JSONDecodeError, TypeError):
285                pass
286
287        return sorted(tags_set)
288
289    def contains(self, name: str) -> bool:
290        """检查主机是否存在"""
291        with self._lock, self._txn() as conn:
292            row = conn.execute("SELECT 1 FROM hosts WHERE name = ?", (name,)).fetchone()
293
294        return row is not None
295
296    def count(self) -> int:
297        """返回主机数量"""
298        with self._lock, self._txn() as conn:
299            row = conn.execute("SELECT COUNT(*) as cnt FROM hosts").fetchone()
300
301        return row["cnt"] if row else 0
302
303    def flush(self) -> None:
304        """
305        SQLite 写入即时生效,flush 为空操作
306        此处仅触发一个检查点以压缩 WAL 日志
307        """
308        with self._lock, self._txn() as conn:
309            conn.execute("PRAGMA wal_checkpoint(TRUNCATE);")
310
311    # ========================================================================
312    # 扩展方法(非 ABC 接口)
313    # ========================================================================
314
315    def search(self, query: str) -> builtins.list[Host]:
316        """
317        模糊搜索主机
318
319        按名称、主机名、用户名、描述进行模糊匹配。
320
321        Args:
322            query: 搜索关键词
323
324        Returns:
325            List[Host]: 匹配的主机列表
326        """
327        pattern = f"%{query}%"
328        with self._lock, self._txn() as conn:
329            rows = conn.execute(
330                """
331                    SELECT * FROM hosts
332                    WHERE name LIKE ?
333                       OR hostname LIKE ?
334                       OR username LIKE ?
335                       OR description LIKE ?
336                    ORDER BY name
337                    """,
338                (pattern, pattern, pattern, pattern),
339            ).fetchall()
340
341        return [self._row_to_host(row) for row in rows]
342
343    def list_paginated(
344        self,
345        offset: int = 0,
346        limit: int = 20,
347        tag: Optional[str] = None,
348    ) -> tuple[builtins.list[Host], int]:
349        """
350        分页查询主机
351
352        Args:
353            offset: 偏移量
354            limit: 每页数量
355            tag: 可选标签筛选
356
357        Returns:
358            Tuple[List[Host], int]: (主机列表, 总数)
359        """
360        with self._lock, self._txn() as conn:
361            if tag:
362                count_row = conn.execute(
363                    "SELECT COUNT(*) as cnt FROM hosts WHERE tags LIKE ?",
364                    (f'%"{tag}"%',),
365                ).fetchone()
366                total = count_row["cnt"] if count_row else 0
367                rows = conn.execute(
368                    "SELECT * FROM hosts WHERE tags LIKE ? ORDER BY name LIMIT ? OFFSET ?",
369                    (f'%"{tag}"%', limit, offset),
370                ).fetchall()
371            else:
372                count_row = conn.execute("SELECT COUNT(*) as cnt FROM hosts").fetchone()
373                total = count_row["cnt"] if count_row else 0
374                rows = conn.execute(
375                    "SELECT * FROM hosts ORDER BY name LIMIT ? OFFSET ?",
376                    (limit, offset),
377                ).fetchall()
378
379        hosts = [self._row_to_host(row) for row in rows]
380        return hosts, total
381
382    # ========================================================================
383    # 内部辅助
384    # ========================================================================
385
386    def _row_to_host(self, row: sqlite3.Row) -> Host:
387        """
388        将 SQLite 行转换为 Host 对象
389
390        Args:
391            row: SQLite 行对象
392
393        Returns:
394            Host: 主机配置对象
395        """
396        # 解析 tags JSON
397        tags = None
398        try:
399            raw_tags = row["tags"]
400            if raw_tags:
401                parsed = json.loads(raw_tags)
402                if isinstance(parsed, list):
403                    tags = parsed
404        except (json.JSONDecodeError, TypeError):
405            pass
406
407        # 配置了加密器时解密密码
408        password = row["password"]
409        if self._guard.is_encrypted(password):
410            password = self._guard.decrypt(password)
411            if password is None:
412                logger.warning("failed to decrypt password for host '%s'", row["name"])
413
414        # tags 解析失败时为 None,归一化为空列表(与 Host 构造器默认行为一致)
415        if tags is None:
416            tags = []
417
418        return Host(
419            name=row["name"],
420            hostname=row["hostname"],
421            username=row["username"],
422            port=row["port"],
423            password=password,
424            key_filename=row["key_filename"],
425            tags=tags,
426            description=row["description"] or "",
427        )

SQLite 主机仓库

Args: db_path: SQLite 数据库文件路径 migrate_from: JSON 文件路径,用于自动迁移(仅首次使用) auto_create: 是否自动创建表和数据库,默认 True encryption: 可选的凭据加密器(设置后 save() 自动加密 password)

注意: 密码的加密依赖传入 encryption。若直接以明文密码调用 save() 且未提供 encryption,明文会被持久化到数据库。请勿绕过 HostService。

SqliteHostRepository( db_path: str, migrate_from: str | None = None, auto_create: bool = True, encryption: CredentialEncryption | None = None)
83    def __init__(
84        self,
85        db_path: str,
86        migrate_from: Optional[str] = None,
87        auto_create: bool = True,
88        encryption: Optional[CredentialEncryption] = None,
89    ) -> None:
90        self._db_path = db_path
91        self._lock = threading.Lock()
92        self._encryption = encryption
93        self._guard = PasswordGuard(encryption)
94
95        if auto_create:
96            self._init_db()
97
98        if migrate_from:
99            self._maybe_migrate_from_json(migrate_from)
def save(self, host: Host) -> None:
206    def save(self, host: Host) -> None:
207        """保存或更新主机"""
208        with self._lock, self._txn() as conn:
209            tags_json = json.dumps(host.tags or [], ensure_ascii=False)
210            # 配置了加密器时,明文密码先加密再落库
211            password = self._guard.encrypt(host.password)
212            conn.execute(
213                """
214                    INSERT INTO hosts (name, hostname, username, port, password,
215                                       key_filename, tags, description, updated_at)
216                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
217                    ON CONFLICT(name) DO UPDATE SET
218                        hostname = excluded.hostname,
219                        username = excluded.username,
220                        port = excluded.port,
221                        password = excluded.password,
222                        key_filename = excluded.key_filename,
223                        tags = excluded.tags,
224                        description = excluded.description,
225                        updated_at = CURRENT_TIMESTAMP
226                    """,
227                (
228                    host.name,
229                    host.hostname,
230                    host.username,
231                    host.port,
232                    password,
233                    host.key_filename,
234                    tags_json,
235                    host.description,
236                ),
237            )
238            conn.commit()

保存或更新主机

def get(self, name: str) -> Host:
240    def get(self, name: str) -> Host:
241        """按名称获取主机"""
242        with self._lock, self._txn() as conn:
243            row = conn.execute("SELECT * FROM hosts WHERE name = ?", (name,)).fetchone()
244
245        if row is None:
246            raise KeyError(f"Host '{name}' not found")
247
248        return self._row_to_host(row)

按名称获取主机

def delete(self, name: str) -> None:
250    def delete(self, name: str) -> None:
251        """按名称删除主机"""
252        with self._lock, self._txn() as conn:
253            cursor = conn.execute("DELETE FROM hosts WHERE name = ?", (name,))
254            conn.commit()
255
256        if cursor.rowcount == 0:
257            raise KeyError(f"Host '{name}' not found")

按名称删除主机

def list(unknown):
259    def list(self, tag: Optional[str] = None) -> list[Host]:
260        """列出主机,可选按标签筛选"""
261        with self._lock, self._txn() as conn:
262            if tag:
263                # 使用 LIKE 匹配 tags JSON 中的标签
264                rows = conn.execute(
265                    "SELECT * FROM hosts WHERE tags LIKE ? ORDER BY name",
266                    (f'%"{tag}"%',),
267                ).fetchall()
268            else:
269                rows = conn.execute("SELECT * FROM hosts ORDER BY name").fetchall()
270
271        return [self._row_to_host(row) for row in rows]

列出主机,可选按标签筛选

def list_tags(self) -> list[str]:
273    def list_tags(self) -> builtins.list[str]:
274        """列出所有标签"""
275        with self._lock, self._txn() as conn:
276            rows = conn.execute("SELECT DISTINCT tags FROM hosts WHERE tags IS NOT NULL").fetchall()
277
278        tags_set: set = set()
279        for row in rows:
280            try:
281                tags = json.loads(row["tags"] or "[]")
282                if isinstance(tags, list):
283                    tags_set.update(tags)
284            except (json.JSONDecodeError, TypeError):
285                pass
286
287        return sorted(tags_set)

列出所有标签

def contains(self, name: str) -> bool:
289    def contains(self, name: str) -> bool:
290        """检查主机是否存在"""
291        with self._lock, self._txn() as conn:
292            row = conn.execute("SELECT 1 FROM hosts WHERE name = ?", (name,)).fetchone()
293
294        return row is not None

检查主机是否存在

def count(self) -> int:
296    def count(self) -> int:
297        """返回主机数量"""
298        with self._lock, self._txn() as conn:
299            row = conn.execute("SELECT COUNT(*) as cnt FROM hosts").fetchone()
300
301        return row["cnt"] if row else 0

返回主机数量

def flush(self) -> None:
303    def flush(self) -> None:
304        """
305        SQLite 写入即时生效,flush 为空操作
306        此处仅触发一个检查点以压缩 WAL 日志
307        """
308        with self._lock, self._txn() as conn:
309            conn.execute("PRAGMA wal_checkpoint(TRUNCATE);")

SQLite 写入即时生效,flush 为空操作 此处仅触发一个检查点以压缩 WAL 日志

def search(self, query: str) -> list[Host]:
315    def search(self, query: str) -> builtins.list[Host]:
316        """
317        模糊搜索主机
318
319        按名称、主机名、用户名、描述进行模糊匹配。
320
321        Args:
322            query: 搜索关键词
323
324        Returns:
325            List[Host]: 匹配的主机列表
326        """
327        pattern = f"%{query}%"
328        with self._lock, self._txn() as conn:
329            rows = conn.execute(
330                """
331                    SELECT * FROM hosts
332                    WHERE name LIKE ?
333                       OR hostname LIKE ?
334                       OR username LIKE ?
335                       OR description LIKE ?
336                    ORDER BY name
337                    """,
338                (pattern, pattern, pattern, pattern),
339            ).fetchall()
340
341        return [self._row_to_host(row) for row in rows]

模糊搜索主机

按名称、主机名、用户名、描述进行模糊匹配。

Args: query: 搜索关键词

Returns: List[Host]: 匹配的主机列表

def list_paginated( self, offset: int = 0, limit: int = 20, tag: str | None = None) -> tuple[list[Host], int]:
343    def list_paginated(
344        self,
345        offset: int = 0,
346        limit: int = 20,
347        tag: Optional[str] = None,
348    ) -> tuple[builtins.list[Host], int]:
349        """
350        分页查询主机
351
352        Args:
353            offset: 偏移量
354            limit: 每页数量
355            tag: 可选标签筛选
356
357        Returns:
358            Tuple[List[Host], int]: (主机列表, 总数)
359        """
360        with self._lock, self._txn() as conn:
361            if tag:
362                count_row = conn.execute(
363                    "SELECT COUNT(*) as cnt FROM hosts WHERE tags LIKE ?",
364                    (f'%"{tag}"%',),
365                ).fetchone()
366                total = count_row["cnt"] if count_row else 0
367                rows = conn.execute(
368                    "SELECT * FROM hosts WHERE tags LIKE ? ORDER BY name LIMIT ? OFFSET ?",
369                    (f'%"{tag}"%', limit, offset),
370                ).fetchall()
371            else:
372                count_row = conn.execute("SELECT COUNT(*) as cnt FROM hosts").fetchone()
373                total = count_row["cnt"] if count_row else 0
374                rows = conn.execute(
375                    "SELECT * FROM hosts ORDER BY name LIMIT ? OFFSET ?",
376                    (limit, offset),
377                ).fetchall()
378
379        hosts = [self._row_to_host(row) for row in rows]
380        return hosts, total

分页查询主机

Args: offset: 偏移量 limit: 每页数量 tag: 可选标签筛选

Returns: Tuple[List[Host], int]: (主机列表, 总数)

class BatchExecutor:
 63class BatchExecutor:
 64    """
 65    批量命令执行器
 66
 67    支持多主机并发执行,带超时控制、失败重试和进度回调。
 68
 69    Args:
 70        host_service: HostService 实例,用于获取主机配置和凭据
 71        max_concurrency: 最大并发数,默认 10
 72        command_timeout: 单个命令超时时间(秒),默认 30
 73        use_async: 是否启用异步内核(基于 asyncssh 的原生异步实现)。默认 False
 74            保持原有 ThreadPoolExecutor 行为;设为 True 时,execute 会在内部使用
 75            asyncio.run 调用 AsyncBatchExecutor 完成并发调度,从而在大规模场景下
 76            降低线程/CPU 开销。注意:启用时调用线程不应已运行 asyncio 事件循环,
 77            否则抛出 RuntimeError(应改用 AsyncBatchExecutor.execute())。
 78        pool_factory: 外部连接池工厂(可选,v2.1)。提供时执行器从工厂获取
 79            池并复用其连接,**绝不关闭**返回的池(所有权归调用方);
 80            未提供时多主机或需重试时内部按主机创建 SyncConnectionPool
 81            (use_async=True 时为 AsyncConnectionPool),执行结束后自动关闭。
 82            工厂返回的池类型须与内核匹配(见 PoolFactory 注释)。
 83
 84    连接池所有权约定(与 AsyncBatchExecutor 一致):
 85
 86    - 外部注入(``pool_factory``)→ 调用方拥有,executor 只借用不关闭;
 87      适合长驻服务跨批次复用连接。
 88    - 内部创建 → executor 拥有,单次 ``execute`` 结束后 ``close_all``;
 89      适合一次性脚本。
 90
 91    Note:
 92        无论 `use_async` 取值,对外 `execute` 始终为同步接口,返回类型一致,
 93        便于上层无差别切换。
 94    """
 95
 96    def __init__(
 97        self,
 98        host_service: HostService,
 99        max_concurrency: int = 10,
100        command_timeout: int = 30,
101        use_async: bool = False,
102        pool_factory: Optional[PoolFactory] = None,
103    ) -> None:
104        if max_concurrency < 1:
105            raise ValueError(f"max_concurrency must be >= 1, got: {max_concurrency}")
106        if command_timeout <= 0:
107            raise ValueError(f"command_timeout must be > 0, got: {command_timeout}")
108        self._host_service = host_service
109        self._max_concurrency = max_concurrency
110        self._command_timeout = command_timeout
111        self._use_async = use_async
112        self._pool_factory = pool_factory
113        # 延迟导入以避免在未安装 asyncssh 的环境下的导入失败
114        # 使用前向引用避免在模块加载期引入 asyncssh 硬依赖(开启 use_async 时才惰性导入)
115        self._async_executor: Optional["AsyncBatchExecutor"] = None  # noqa: UP037
116        if use_async:
117            from remote_cmd.service.async_batch_executor import AsyncBatchExecutor
118
119            self._async_executor = AsyncBatchExecutor(
120                host_service=host_service,
121                max_concurrency=max_concurrency,
122                command_timeout=command_timeout,
123                pool_factory=pool_factory,
124            )
125
126    def execute(
127        self,
128        host_names: list[str],
129        command: str,
130        retry_count: int = 0,
131        retry_delay: float = 1.0,
132        progress_callback: Optional[ProgressCallback] = None,
133    ) -> BatchResult:
134        """
135        在指定主机上批量执行命令
136
137        Args:
138            host_names: 要执行命令的主机名称列表
139            command: 要执行的命令
140            retry_count: 失败重试次数,默认 0(不重试)。仅对瞬态错误
141                (超时/网络中断等)重试;认证、凭据、配置等永久性错误
142                立即失败(分类见 service/retry_policy.py)
143            retry_delay: 重试基础延迟(秒),默认 1.0。实际等待为
144                指数退避 + full jitter:第 n 次失败后等待
145                0 到 retry_delay * 2^n(含端点)内的随机值(上限 60s)
146            progress_callback: 进度回调,参数 (completed, total, current_host_name)。
147                同步内核下回调应为同步函数;异步回调请使用 use_async=True。
148
149        Returns:
150            BatchResult: 批量执行结果
151
152        Raises:
153            ValueError: host_names 为空
154        """
155        if not host_names:
156            raise ValueError("host_names must not be empty")
157        if retry_count < 0:
158            raise ValueError(f"retry_count must be >= 0, got: {retry_count}")
159        if retry_delay < 0:
160            raise ValueError(f"retry_delay must be >= 0, got: {retry_delay}")
161
162        # 去重(保留首次出现顺序):重复主机名只执行一次,避免 results 覆盖导致
163        # total/success/failed 统计错位
164        host_names = list(dict.fromkeys(host_names))
165
166        # 异步内核委托路径:同步接口 + asyncio.run(异步实现)
167        if self._async_executor is not None:
168            return self._delegate_to_async(
169                host_names, command, retry_count, retry_delay, progress_callback
170            )
171
172        return self._execute_sync(host_names, command, retry_count, retry_delay, progress_callback)
173
174    def _delegate_to_async(
175        self,
176        host_names: list[str],
177        command: str,
178        retry_count: int,
179        retry_delay: float,
180        progress_callback: Optional[ProgressCallback],
181    ) -> BatchResult:
182        """异步内核委托路径:同步接口 + asyncio.run(异步实现)"""
183        # 仅当 _async_executor 已初始化时才进入此路径(见 execute() 的窄化判断)
184        assert self._async_executor is not None
185
186        import asyncio
187
188        # 前置检测运行中的事件循环:asyncio.run() 在事件循环内调用只会抛出
189        # 通用 Python 错误,替换为可操作的项目级错误提示。
190        # 仅此场景抛出,其余 RuntimeError(来自 asyncio.run 本身的其他
191        # 失败原因)不经本分支改写。
192        try:
193            asyncio.get_running_loop()
194        except RuntimeError:
195            pass  # 无运行中的事件循环,asyncio.run 可安全使用
196        else:
197            raise RuntimeError(
198                "BatchExecutor(use_async=True) cannot be used inside a running "
199                "event loop; use AsyncBatchExecutor.execute() directly instead"
200            )
201
202        return asyncio.run(
203            self._async_executor.execute(
204                host_names=host_names,
205                command=command,
206                retry_count=retry_count,
207                retry_delay=retry_delay,
208                progress_callback=progress_callback,
209            )
210        )
211
212    def _execute_sync(
213        self,
214        host_names: list[str],
215        command: str,
216        retry_count: int,
217        retry_delay: float,
218        progress_callback: Optional[ProgressCallback],
219    ) -> BatchResult:
220        """同步路径:ThreadPoolExecutor + 连接池复用"""
221        total = len(host_names)
222        results: dict[str, BatchHostResult] = {}
223        start_time = time.time()
224
225        logger.info(f"batch execution started: {total} hosts, concurrency={self._max_concurrency}")
226
227        pools: dict[str, SyncConnectionPool] = {}
228        # 内部创建的池由本批次负责关闭;外部 pool_factory 提供的池
229        # 所有权归调用方,绝不登记进此列表
230        internal_pools: list[SyncConnectionPool] = []
231
232        try:
233            with ThreadPoolExecutor(max_workers=self._max_concurrency) as executor:
234                future_map = self._submit_tasks(
235                    executor,
236                    host_names,
237                    command,
238                    retry_count,
239                    retry_delay,
240                    pools,
241                    internal_pools,
242                    total,
243                )
244                self._collect_results(
245                    future_map, host_names, command, progress_callback, results, total
246                )
247        finally:
248            # 仅关闭内部创建的池;外部 pool_factory 提供的池所有权归调用方
249            self._cleanup_pools(internal_pools)
250
251        duration = time.time() - start_time
252        return self._build_result(total, results, duration)
253
254    def _submit_tasks(
255        self,
256        executor: ThreadPoolExecutor,
257        host_names: list[str],
258        command: str,
259        retry_count: int,
260        retry_delay: float,
261        pools: dict[str, SyncConnectionPool],
262        internal_pools: list[SyncConnectionPool],
263        total: int,
264    ) -> dict:
265        """提交任务到线程池,返回 future_map"""
266        future_map = {}
267        for host_name in host_names:
268            # 连接池:外部注入时始终启用;否则多主机或需重试时创建
269            pool: Optional[SyncConnectionPool] = None
270            if self._pool_factory is not None or retry_count > 0 or total > 1:
271                pool = self._prepare_pool(host_name, pools, internal_pools)
272            future = executor.submit(
273                self._execute_on_host, host_name, command, retry_count, retry_delay, pool
274            )
275            future_map[future] = host_name
276        return future_map
277
278    def _prepare_pool(
279        self,
280        host_name: str,
281        pools: dict[str, SyncConnectionPool],
282        internal_pools: list[SyncConnectionPool],
283    ) -> Optional[SyncConnectionPool]:
284        """为指定主机创建或获取连接池
285
286        主机解析失败时返回 None 而非上抛:保持 execute 的
287        "未知主机 → BatchHostResult 错误条目" 契约(由
288        _execute_on_host 的 resolve_host_or_error 记录失败详情),
289        避免整个批次因单个坏主机以异常收场。
290
291        池所有权:外部 ``pool_factory`` 提供的池绝不登记进
292        internal_pools(executor 不负责关闭);内部创建的池登记后
293        由 _cleanup_pools 统一 close_all。
294        """
295        if host_name in pools:
296            return pools[host_name]
297
298        try:
299            host = self._host_service.resolve_host(host_name)
300        except Exception as e:  # noqa: BLE001
301            logger.debug(f"pool preparation skipped for {host_name}: {e}")
302            return None
303        config = build_connection_config(host, self._command_timeout)
304        if self._pool_factory is not None:
305            pool = self._pool_factory(config)
306            pools[host_name] = pool
307            return pool
308        pool = SyncConnectionPool(
309            config,
310            max_connections=max(1, self._max_concurrency),
311            client_factory=SSHClient,
312        )
313        pools[host_name] = pool
314        internal_pools.append(pool)
315        return pool
316
317    def _collect_results(
318        self,
319        future_map: dict,
320        host_names: list[str],
321        command: str,
322        progress_callback: Optional[ProgressCallback],
323        results: dict[str, BatchHostResult],
324        total: int,
325    ) -> int:
326        """收集结果并处理进度回调与中断"""
327        completed = 0
328        try:
329            for future in as_completed(future_map):
330                host_name = future_map[future]
331                result = self._process_future_result(future, host_name, command)
332                results[host_name] = result
333                completed += 1
334                self._invoke_progress_callback(
335                    progress_callback, completed, total, host_name, result
336                )
337        except KeyboardInterrupt:
338            logger.warning("batch execution interrupted by user")
339            self._handle_interrupt(future_map, host_names, command, results)
340            completed = len(results)
341
342        return completed
343
344    def _process_future_result(self, future, host_name: str, command: str) -> BatchHostResult:
345        """处理单个 future 结果,捕获调度异常"""
346        try:
347            return future.result()
348        except Exception as e:  # noqa: BLE001
349            return BatchHostResult(
350                host=host_name,
351                success=False,
352                command=command,
353                error=f"scheduling error: {e}",
354            )
355
356    def _invoke_progress_callback(
357        self,
358        progress_callback: Optional[ProgressCallback],
359        completed: int,
360        total: int,
361        host_name: str,
362        result: BatchHostResult,
363    ) -> None:
364        """调用进度回调并记录日志"""
365        if progress_callback:
366            rv = progress_callback(completed, total, host_name)
367            if isinstance(rv, Coroutine):
368                logger.warning("同步内核不支持异步进度回调,请使用 use_async=True")
369                # 显式关闭未 await 的协程,避免 RuntimeWarning 与资源泄漏
370                rv.close()
371
372        logger.debug(
373            f"[{completed}/{total}] {host_name}: "
374            f"{'✓' if result.success else '✗'} "
375            f"({result.duration:.1f}s)"
376        )
377
378    def _handle_interrupt(
379        self,
380        future_map: dict,
381        host_names: list[str],
382        command: str,
383        results: dict[str, BatchHostResult],
384    ) -> None:
385        """处理键盘中断:取消任务并为未完成主机创建失败记录"""
386        # 取消所有未完成的任务
387        for future in future_map:
388            future.cancel()
389
390        # 为尚未有结果的主机创建失败记录
391        for host_name in host_names:
392            if host_name not in results:
393                results[host_name] = BatchHostResult(
394                    host=host_name,
395                    success=False,
396                    command=command,
397                    error="user interrupted",
398                )
399
400    def _cleanup_pools(self, internal_pools: list[SyncConnectionPool]) -> None:
401        """关闭本批次内部创建的所有连接池(外部提供的池绝不关闭)"""
402        for pool in internal_pools:
403            pool.close_all()
404
405    def _build_result(
406        self, total: int, results: dict[str, BatchHostResult], duration: float
407    ) -> BatchResult:
408        """构建批量执行汇总结果"""
409        success_count = sum(1 for r in results.values() if r.success)
410        failed_count = total - success_count
411
412        logger.info(
413            f"batch execution finished: {success_count}/{total} succeeded, took {duration:.1f}s"
414        )
415
416        return BatchResult(
417            total=total,
418            success=success_count,
419            failed=failed_count,
420            duration=duration,
421            results=results,
422        )
423
424    def _execute_on_host(
425        self,
426        host_name: str,
427        command: str,
428        retry_count: int,
429        retry_delay: float,
430        pool: Optional[SyncConnectionPool] = None,
431    ) -> BatchHostResult:
432        """
433        在单台主机上执行命令(包含重试逻辑)
434
435        Args:
436            host_name: 主机名称
437            command: 要执行的命令
438            retry_count: 重试次数
439            retry_delay: 重试间隔
440
441        Returns:
442            BatchHostResult: 单台主机的执行结果
443        """
444        # 解析主机配置(包括凭据解密);失败返回错误结果
445        outcome = resolve_host_or_error(self._host_service, host_name, command)
446        if isinstance(outcome, BatchHostResult):
447            return outcome
448        host: Host = outcome
449
450        last_error: Optional[str] = None
451        last_duration = 0.0
452
453        for attempt in range(retry_count + 1):
454            start = time.time()
455            try:
456                config = build_connection_config(host, self._command_timeout)
457
458                if pool is not None:
459                    # 连接池模式:复用主机连接,避免每次操作握手
460                    with pool.acquire_context() as client:
461                        cmd_result = client.execute(command, timeout=self._command_timeout)
462                    return to_host_result(host_name, command, cmd_result, time.time() - start)
463
464                # 非连接池路径:try/finally 确保即使 execute() 抛异常,
465                # disconnect() 也会执行,避免 SSH 连接泄漏
466                client = SSHClient(config)
467                try:
468                    client.connect()
469                    cmd_result = client.execute(command, timeout=self._command_timeout)
470                finally:
471                    client.disconnect()
472
473                return to_host_result(host_name, command, cmd_result, time.time() - start)
474
475            except Exception as e:  # noqa: BLE001
476                duration = time.time() - start
477                last_error = str(e)
478                last_duration = duration
479                logger.debug(f"attempt {attempt + 1}/{retry_count + 1} failed for {host_name}: {e}")
480
481                # 已是最后一次尝试,或异常为永久性(认证/凭据/配置错误等),
482                # 立即放弃重试——详见 service/retry_policy.py 的分类契约
483                if attempt >= retry_count:
484                    break
485                if not is_retryable(e):
486                    logger.debug(f"non-retryable error for {host_name}, giving up: {e}")
487                    break
488
489                # 指数退避 + full jitter(避免多主机同步重试的惊群)
490                delay = compute_backoff_delay(attempt, retry_delay)
491                time.sleep(delay)
492
493        # 所有重试都失败
494        return BatchHostResult(
495            host=host_name,
496            success=False,
497            command=command,
498            error=last_error,
499            duration=last_duration,
500        )

批量命令执行器

支持多主机并发执行,带超时控制、失败重试和进度回调。

Args: host_service: HostService 实例,用于获取主机配置和凭据 max_concurrency: 最大并发数,默认 10 command_timeout: 单个命令超时时间(秒),默认 30 use_async: 是否启用异步内核(基于 asyncssh 的原生异步实现)。默认 False 保持原有 ThreadPoolExecutor 行为;设为 True 时,execute 会在内部使用 asyncio.run 调用 AsyncBatchExecutor 完成并发调度,从而在大规模场景下 降低线程/CPU 开销。注意:启用时调用线程不应已运行 asyncio 事件循环, 否则抛出 RuntimeError(应改用 AsyncBatchExecutor.execute())。 pool_factory: 外部连接池工厂(可选,v2.1)。提供时执行器从工厂获取 池并复用其连接,**绝不关闭**返回的池(所有权归调用方); 未提供时多主机或需重试时内部按主机创建 SyncConnectionPool (use_async=True 时为 AsyncConnectionPool),执行结束后自动关闭。 工厂返回的池类型须与内核匹配(见 PoolFactory 注释)。

连接池所有权约定(与 AsyncBatchExecutor 一致):

  • 外部注入(pool_factory)→ 调用方拥有,executor 只借用不关闭; 适合长驻服务跨批次复用连接。
  • 内部创建 → executor 拥有,单次 execute 结束后 close_all; 适合一次性脚本。

Note: 无论 use_async 取值,对外 execute 始终为同步接口,返回类型一致, 便于上层无差别切换。

BatchExecutor( host_service: HostService, max_concurrency: int = 10, command_timeout: int = 30, use_async: bool = False, pool_factory: Callable[[remote_cmd.core.ssh_client.ConnectionConfig], Any] | None = None)
 96    def __init__(
 97        self,
 98        host_service: HostService,
 99        max_concurrency: int = 10,
100        command_timeout: int = 30,
101        use_async: bool = False,
102        pool_factory: Optional[PoolFactory] = None,
103    ) -> None:
104        if max_concurrency < 1:
105            raise ValueError(f"max_concurrency must be >= 1, got: {max_concurrency}")
106        if command_timeout <= 0:
107            raise ValueError(f"command_timeout must be > 0, got: {command_timeout}")
108        self._host_service = host_service
109        self._max_concurrency = max_concurrency
110        self._command_timeout = command_timeout
111        self._use_async = use_async
112        self._pool_factory = pool_factory
113        # 延迟导入以避免在未安装 asyncssh 的环境下的导入失败
114        # 使用前向引用避免在模块加载期引入 asyncssh 硬依赖(开启 use_async 时才惰性导入)
115        self._async_executor: Optional["AsyncBatchExecutor"] = None  # noqa: UP037
116        if use_async:
117            from remote_cmd.service.async_batch_executor import AsyncBatchExecutor
118
119            self._async_executor = AsyncBatchExecutor(
120                host_service=host_service,
121                max_concurrency=max_concurrency,
122                command_timeout=command_timeout,
123                pool_factory=pool_factory,
124            )
def execute( self, host_names: list[str], command: str, retry_count: int = 0, retry_delay: float = 1.0, progress_callback: Callable[[int, int, str], Awaitable[None] | None] | None = None) -> BatchResult:
126    def execute(
127        self,
128        host_names: list[str],
129        command: str,
130        retry_count: int = 0,
131        retry_delay: float = 1.0,
132        progress_callback: Optional[ProgressCallback] = None,
133    ) -> BatchResult:
134        """
135        在指定主机上批量执行命令
136
137        Args:
138            host_names: 要执行命令的主机名称列表
139            command: 要执行的命令
140            retry_count: 失败重试次数,默认 0(不重试)。仅对瞬态错误
141                (超时/网络中断等)重试;认证、凭据、配置等永久性错误
142                立即失败(分类见 service/retry_policy.py)
143            retry_delay: 重试基础延迟(秒),默认 1.0。实际等待为
144                指数退避 + full jitter:第 n 次失败后等待
145                0 到 retry_delay * 2^n(含端点)内的随机值(上限 60s)
146            progress_callback: 进度回调,参数 (completed, total, current_host_name)。
147                同步内核下回调应为同步函数;异步回调请使用 use_async=True。
148
149        Returns:
150            BatchResult: 批量执行结果
151
152        Raises:
153            ValueError: host_names 为空
154        """
155        if not host_names:
156            raise ValueError("host_names must not be empty")
157        if retry_count < 0:
158            raise ValueError(f"retry_count must be >= 0, got: {retry_count}")
159        if retry_delay < 0:
160            raise ValueError(f"retry_delay must be >= 0, got: {retry_delay}")
161
162        # 去重(保留首次出现顺序):重复主机名只执行一次,避免 results 覆盖导致
163        # total/success/failed 统计错位
164        host_names = list(dict.fromkeys(host_names))
165
166        # 异步内核委托路径:同步接口 + asyncio.run(异步实现)
167        if self._async_executor is not None:
168            return self._delegate_to_async(
169                host_names, command, retry_count, retry_delay, progress_callback
170            )
171
172        return self._execute_sync(host_names, command, retry_count, retry_delay, progress_callback)

在指定主机上批量执行命令

Args: host_names: 要执行命令的主机名称列表 command: 要执行的命令 retry_count: 失败重试次数,默认 0(不重试)。仅对瞬态错误 (超时/网络中断等)重试;认证、凭据、配置等永久性错误 立即失败(分类见 service/retry_policy.py) retry_delay: 重试基础延迟(秒),默认 1.0。实际等待为 指数退避 + full jitter:第 n 次失败后等待 0 到 retry_delay * 2^n(含端点)内的随机值(上限 60s) progress_callback: 进度回调,参数 (completed, total, current_host_name)。 同步内核下回调应为同步函数;异步回调请使用 use_async=True。

Returns: BatchResult: 批量执行结果

Raises: ValueError: host_names 为空

@dataclass
class BatchResult:
47@dataclass
48class BatchResult:
49    """
50    批量执行汇总结果
51
52    Attributes:
53        total: 总主机数
54        success: number of succeeded hosts
55        failed: 失败主机数
56        duration: total took (seconds)
57        results: 按主机名索引的详细结果
58    """
59
60    total: int
61    success: int
62    failed: int
63    duration: float
64    results: dict[str, BatchHostResult] = field(default_factory=dict)
65
66    @property
67    def success_rate(self) -> float:
68        """success rate (0.0 ~ 1.0)"""
69        if self.total == 0:
70            return 1.0
71        return self.success / self.total
72
73    @property
74    def failed_hosts(self) -> list[str]:
75        """失败主机列表"""
76        return [h for h, r in self.results.items() if not r.success]
77
78    @property
79    def success_hosts(self) -> list[str]:
80        """list of succeeded hosts"""
81        return [h for h, r in self.results.items() if r.success]
82
83    def summary(self) -> str:
84        """生成可读的汇总字符串"""
85        return (
86            f"Total: {self.total}, "
87            f"Succeeded: {self.success}, "
88            f"Failed: {self.failed}, "
89            f"Duration: {self.duration:.1f}s, "
90            f"Success rate: {self.success_rate:.1%}"
91        )

批量执行汇总结果

Attributes: total: 总主机数 success: number of succeeded hosts failed: 失败主机数 duration: total took (seconds) results: 按主机名索引的详细结果

BatchResult( total: int, success: int, failed: int, duration: float, results: dict[str, BatchHostResult] = <factory>)
total: int
success: int
failed: int
duration: float
results: dict[str, BatchHostResult]
success_rate: float
66    @property
67    def success_rate(self) -> float:
68        """success rate (0.0 ~ 1.0)"""
69        if self.total == 0:
70            return 1.0
71        return self.success / self.total

success rate (0.0 ~ 1.0)

failed_hosts: list[str]
73    @property
74    def failed_hosts(self) -> list[str]:
75        """失败主机列表"""
76        return [h for h, r in self.results.items() if not r.success]

失败主机列表

success_hosts: list[str]
78    @property
79    def success_hosts(self) -> list[str]:
80        """list of succeeded hosts"""
81        return [h for h, r in self.results.items() if r.success]

list of succeeded hosts

def summary(self) -> str:
83    def summary(self) -> str:
84        """生成可读的汇总字符串"""
85        return (
86            f"Total: {self.total}, "
87            f"Succeeded: {self.success}, "
88            f"Failed: {self.failed}, "
89            f"Duration: {self.duration:.1f}s, "
90            f"Success rate: {self.success_rate:.1%}"
91        )

生成可读的汇总字符串

@dataclass
class BatchHostResult:
21@dataclass
22class BatchHostResult:
23    """
24    单个主机的批量执行结果
25
26    Attributes:
27        host: 主机名称
28        success: whether the command succeeded
29        command: 执行的命令
30        stdout: 标准输出
31        stderr: 标准错误
32        exit_code: 退出码
33        duration: execution took (seconds)
34        error: 错误信息(如果有)
35    """
36
37    host: str
38    success: bool
39    command: str
40    stdout: str = ""
41    stderr: str = ""
42    exit_code: int = -1
43    duration: float = 0.0
44    error: Optional[str] = None

单个主机的批量执行结果

Attributes: host: 主机名称 success: whether the command succeeded command: 执行的命令 stdout: 标准输出 stderr: 标准错误 exit_code: 退出码 duration: execution took (seconds) error: 错误信息(如果有)

BatchHostResult( host: str, success: bool, command: str, stdout: str = '', stderr: str = '', exit_code: int = -1, duration: float = 0.0, error: str | None = None)
host: str
success: bool
command: str
stdout: str = ''
stderr: str = ''
exit_code: int = -1
duration: float = 0.0
error: str | None = None
class SyncConnectionPool:
 34class SyncConnectionPool:
 35    """同步 SSH 连接池。
 36
 37    Args:
 38        config: 用于建立 SSH 连接的配置
 39        max_connections: 最大连接数(同一配置可复用)
 40        max_lifetime: 连接最大生命周期(秒),超过自动关闭
 41        idle_timeout: 空闲超时(秒),超过自动关闭
 42        health_check_interval: 后台清理线程周期(秒)
 43    """
 44
 45    def __init__(
 46        self,
 47        config: ConnectionConfig,
 48        max_connections: int = 10,
 49        max_lifetime: int = 3600,
 50        idle_timeout: int = 300,
 51        health_check_interval: int = 60,
 52        client_factory: Optional[Any] = None,
 53    ) -> None:
 54        self.config = config
 55        self._max = max_connections
 56        self._max_lifetime = max_lifetime
 57        self._idle_timeout = idle_timeout
 58        self._health_check_interval = health_check_interval
 59        # 客户端工厂:默认为 SSHClient;测试可注入 mock
 60        self._client_factory = client_factory or SSHClient
 61
 62        # 容器
 63        self._connections: list[SSHClient] = []
 64        self._free: queue.Queue[SSHClient] = queue.Queue()
 65        self._semaphore = threading.Semaphore(max_connections)
 66        self._lock = threading.Lock()
 67
 68        # 生命周期状态:close_all() 后置 True,禁止再借用/归还
 69        self._closed = False
 70
 71        # 指标
 72        self._total_created = 0
 73        self._total_reconnects = 0
 74        self._total_failed = 0
 75        self._total_released = 0
 76
 77        # 后台清理线程
 78        self._monitor_thread: Optional[threading.Thread] = None
 79        self._stop_event = threading.Event()
 80
 81        # 连接元数据(副表,避免侵入 SSHClient 私有属性)
 82        self._meta: dict[int, ConnectionMeta] = {}
 83
 84    # ------------------------------------------------------------------
 85    # 指标
 86    # ------------------------------------------------------------------
 87    def get_metrics(self) -> dict[str, Any]:
 88        """获取连接池指标快照。"""
 89        return {
 90            # 当前在用的连接数 = 存活连接总数 - 空闲连接数。
 91            # 不能用 total_created - total_released:复用连接时
 92            # total_released 会超过 total_created,导致 active 为负。
 93            "active": len(self._connections) - self._free.qsize(),
 94            "idle": self._free.qsize(),
 95            "total_connections": len(self._connections),
 96            "total_created": self._total_created,
 97            "reconnects": self._total_reconnects,
 98            "failed": self._total_failed,
 99            "max_connections": self._max,
100            "max_lifetime": self._max_lifetime,
101            "idle_timeout": self._idle_timeout,
102        }
103
104    # ------------------------------------------------------------------
105    # 获取 / 释放
106    # ------------------------------------------------------------------
107    def acquire(self) -> SSHClient:
108        """从池中获取一个可用连接,必要时创建新连接。
109
110        Returns:
111            SSHClient: 可用的同步客户端
112
113        Raises:
114            SSHConnectionError: 创建连接失败
115            RuntimeError: 连接池已关闭(close_all 之后)
116        """
117        if self._closed:
118            raise RuntimeError("connection pool is closed")
119        self._semaphore.acquire()
120        # 竞态守卫:等待信号量期间 close_all() 可能已完成——
121        # 取得槽位后必须复查,已关闭则归还槽位并抛出既有错误,
122        # 否则会向调用方发放来自已关闭池的连接
123        if self._closed:
124            self._semaphore.release()
125            raise RuntimeError("connection pool is closed")
126        try:
127            # 优先复用空闲连接
128            while not self._free.empty():
129                conn = self._free.get_nowait()
130                if self._check_connection(conn):
131                    self._touch(conn)
132                    return conn
133                self._close_connection(conn)
134
135            # 创建新连接(信号量已保证未超额)
136            return self._create_connection()
137        except BaseException:
138            self._semaphore.release()
139            raise
140
141    def release(self, conn: Optional[SSHClient]) -> None:
142        """归还连接到池中(如已断开/超时则关闭)。"""
143        if conn is None:
144            return
145        # 池已关闭:不把连接放回空闲队列(避免游离连接),直接关闭并释放槽位
146        if self._closed:
147            self._close_connection(conn)
148            self._semaphore.release()
149            self._total_released += 1
150            return
151        meta = self._meta.get(id(conn))
152        if meta is not None:
153            meta.last_used = time.time()
154
155        if not conn.is_connected():
156            self._close_connection(conn)
157            self._semaphore.release()
158            return
159
160        # 生命周期 / 空闲超时则关闭
161        if meta and should_close(meta, self._max_lifetime, self._idle_timeout, True):
162            self._close_connection(conn)
163            self._semaphore.release()
164            return
165
166        try:
167            with self._lock:
168                self._free.put_nowait(conn)
169            # 放回 free 后释放许可:free 中的连接不再占用并发槽位,
170            # 后续 acquire 会从 free 直接复用(无需再次获取许可)
171            self._semaphore.release()
172        except queue.Full:
173            self._close_connection(conn)
174            self._semaphore.release()
175        finally:
176            self._total_released += 1
177
178    # ------------------------------------------------------------------
179    # 内部
180    # ------------------------------------------------------------------
181    def _create_connection(self) -> SSHClient:
182        client = self._client_factory(self.config)
183        try:
184            client.connect()
185        except Exception:  # noqa: BLE001
186            # 信号量由 acquire() 的 except 统一释放,此处不再释放
187            self._total_failed += 1
188            raise
189        with self._lock:
190            self._connections.append(client)
191        now = time.time()
192        self._meta[id(client)] = ConnectionMeta(
193            created_at=now,
194            last_used=now,
195            conn_id=uuid.uuid4().hex,
196        )
197        self._total_created += 1
198        return client
199
200    def _touch(self, conn: SSHClient) -> None:
201        meta = self._meta.get(id(conn))
202        if meta is not None:
203            meta.last_used = time.time()
204
205    def _check_connection(self, conn: SSHClient) -> bool:
206        if not conn.is_connected():
207            return False
208        meta = self._meta.get(id(conn))
209        if meta is None:
210            return True
211        if lifetime_expired(meta.created_at, self._max_lifetime):
212            logger.debug("connection %s exceeded max lifetime", meta.conn_id[:8])
213            return False
214        # 连接刚使用过(空闲未超时)则信任其状态,避免频繁探活开销
215        if not idle_expired(meta.last_used, self._idle_timeout):
216            return True
217        # 空闲较久才触发轻量探活:发出一个无害命令
218        try:
219            result = conn.execute("true", timeout=5)
220            return result.success
221        except Exception as e:  # noqa: BLE001
222            self._total_reconnects += 1
223            logger.debug("connection liveness check failed: %s", e)
224            return False
225
226    def _close_connection(self, conn: SSHClient) -> None:
227        with contextlib.suppress(Exception):
228            conn.disconnect()
229        self._meta.pop(id(conn), None)
230        with self._lock:
231            if conn in self._connections:
232                self._connections.remove(conn)
233
234    # ------------------------------------------------------------------
235    # 后台监控
236    # ------------------------------------------------------------------
237    def start_monitor(self) -> None:
238        """启动后台清理线程(幂等)。"""
239        if self._monitor_thread is not None and self._monitor_thread.is_alive():
240            return
241        self._stop_event.clear()
242        self._monitor_thread = threading.Thread(
243            target=self._monitor_loop,
244            name="sync-connection-pool-monitor",
245            daemon=True,
246        )
247        self._monitor_thread.start()
248
249    def stop_monitor(self) -> None:
250        """停止后台清理线程。"""
251        self._stop_event.set()
252        if self._monitor_thread and self._monitor_thread.is_alive():
253            self._monitor_thread.join(timeout=2.0)
254
255    def _monitor_loop(self) -> None:
256        while not self._stop_event.is_set():
257            try:
258                self._stop_event.wait(self._health_check_interval)
259                if self._stop_event.is_set():
260                    break
261                self._cleanup_expired()
262            except Exception:  # noqa: BLE001
263                logger.warning("connection pool monitor error")
264
265    def _cleanup_expired(self) -> None:
266        now = time.time()
267        # 在锁保护下排空 _free 快照,绝不替换队列对象。
268        # 旧实现 self._free = kept 会替换队列对象,在替换窗口内 release()
269        # 可能 put 到旧队列导致连接泄漏(未关闭、信号量已释放、池中不可见)。
270        with self._lock:
271            snapshot: list[SSHClient] = []
272            while not self._free.empty():
273                snapshot.append(self._free.get_nowait())
274        keep: list[SSHClient] = []
275        for conn in snapshot:
276            meta = self._meta.get(id(conn))
277            if should_close(meta, self._max_lifetime, self._idle_timeout, conn.is_connected(), now):
278                self._close_connection(conn)
279                continue
280            keep.append(conn)
281        # 将存活连接放回同一队列对象
282        with self._lock:
283            for conn in keep:
284                self._free.put_nowait(conn)
285
286    # ------------------------------------------------------------------
287    # 上下文管理
288    # ------------------------------------------------------------------
289    def acquire_context(self) -> "_AcquireContext":
290        """获取连接的上下文管理器。"""
291        return SyncConnectionPool._AcquireContext(self)
292
293    class _AcquireContext:
294        def __init__(self, pool: "SyncConnectionPool") -> None:
295            self._pool = pool
296            self._conn: Optional[SSHClient] = None
297
298        def __enter__(self) -> SSHClient:
299            self._conn = self._pool.acquire()
300            return self._conn
301
302        def __exit__(self, exc_type, exc, tb) -> None:
303            self._pool.release(self._conn)
304            self._conn = None
305
306    def close_all(self) -> None:
307        """关闭池中所有连接并停止监控。"""
308        self._closed = True
309        self.stop_monitor()
310        with self._lock:
311            conns = list(self._connections)
312        for conn in conns:
313            self._close_connection(conn)
314        # 释放所有信号量
315        while not self._free.empty():
316            self._free.get_nowait()
317
318    def __enter__(self) -> "SyncConnectionPool":
319        self.start_monitor()
320        return self
321
322    def __exit__(self, exc_type, exc, tb) -> None:
323        self.close_all()

同步 SSH 连接池。

Args: config: 用于建立 SSH 连接的配置 max_connections: 最大连接数(同一配置可复用) max_lifetime: 连接最大生命周期(秒),超过自动关闭 idle_timeout: 空闲超时(秒),超过自动关闭 health_check_interval: 后台清理线程周期(秒)

SyncConnectionPool( config: remote_cmd.core.ssh_client.ConnectionConfig, max_connections: int = 10, max_lifetime: int = 3600, idle_timeout: int = 300, health_check_interval: int = 60, client_factory: Any | None = None)
45    def __init__(
46        self,
47        config: ConnectionConfig,
48        max_connections: int = 10,
49        max_lifetime: int = 3600,
50        idle_timeout: int = 300,
51        health_check_interval: int = 60,
52        client_factory: Optional[Any] = None,
53    ) -> None:
54        self.config = config
55        self._max = max_connections
56        self._max_lifetime = max_lifetime
57        self._idle_timeout = idle_timeout
58        self._health_check_interval = health_check_interval
59        # 客户端工厂:默认为 SSHClient;测试可注入 mock
60        self._client_factory = client_factory or SSHClient
61
62        # 容器
63        self._connections: list[SSHClient] = []
64        self._free: queue.Queue[SSHClient] = queue.Queue()
65        self._semaphore = threading.Semaphore(max_connections)
66        self._lock = threading.Lock()
67
68        # 生命周期状态:close_all() 后置 True,禁止再借用/归还
69        self._closed = False
70
71        # 指标
72        self._total_created = 0
73        self._total_reconnects = 0
74        self._total_failed = 0
75        self._total_released = 0
76
77        # 后台清理线程
78        self._monitor_thread: Optional[threading.Thread] = None
79        self._stop_event = threading.Event()
80
81        # 连接元数据(副表,避免侵入 SSHClient 私有属性)
82        self._meta: dict[int, ConnectionMeta] = {}
config
def get_metrics(self) -> dict[str, typing.Any]:
 87    def get_metrics(self) -> dict[str, Any]:
 88        """获取连接池指标快照。"""
 89        return {
 90            # 当前在用的连接数 = 存活连接总数 - 空闲连接数。
 91            # 不能用 total_created - total_released:复用连接时
 92            # total_released 会超过 total_created,导致 active 为负。
 93            "active": len(self._connections) - self._free.qsize(),
 94            "idle": self._free.qsize(),
 95            "total_connections": len(self._connections),
 96            "total_created": self._total_created,
 97            "reconnects": self._total_reconnects,
 98            "failed": self._total_failed,
 99            "max_connections": self._max,
100            "max_lifetime": self._max_lifetime,
101            "idle_timeout": self._idle_timeout,
102        }

获取连接池指标快照。

def acquire(self) -> SSHClient:
107    def acquire(self) -> SSHClient:
108        """从池中获取一个可用连接,必要时创建新连接。
109
110        Returns:
111            SSHClient: 可用的同步客户端
112
113        Raises:
114            SSHConnectionError: 创建连接失败
115            RuntimeError: 连接池已关闭(close_all 之后)
116        """
117        if self._closed:
118            raise RuntimeError("connection pool is closed")
119        self._semaphore.acquire()
120        # 竞态守卫:等待信号量期间 close_all() 可能已完成——
121        # 取得槽位后必须复查,已关闭则归还槽位并抛出既有错误,
122        # 否则会向调用方发放来自已关闭池的连接
123        if self._closed:
124            self._semaphore.release()
125            raise RuntimeError("connection pool is closed")
126        try:
127            # 优先复用空闲连接
128            while not self._free.empty():
129                conn = self._free.get_nowait()
130                if self._check_connection(conn):
131                    self._touch(conn)
132                    return conn
133                self._close_connection(conn)
134
135            # 创建新连接(信号量已保证未超额)
136            return self._create_connection()
137        except BaseException:
138            self._semaphore.release()
139            raise

从池中获取一个可用连接,必要时创建新连接。

Returns: SSHClient: 可用的同步客户端

Raises: SSHConnectionError: 创建连接失败 RuntimeError: 连接池已关闭(close_all 之后)

def release(self, conn: SSHClient | None) -> None:
141    def release(self, conn: Optional[SSHClient]) -> None:
142        """归还连接到池中(如已断开/超时则关闭)。"""
143        if conn is None:
144            return
145        # 池已关闭:不把连接放回空闲队列(避免游离连接),直接关闭并释放槽位
146        if self._closed:
147            self._close_connection(conn)
148            self._semaphore.release()
149            self._total_released += 1
150            return
151        meta = self._meta.get(id(conn))
152        if meta is not None:
153            meta.last_used = time.time()
154
155        if not conn.is_connected():
156            self._close_connection(conn)
157            self._semaphore.release()
158            return
159
160        # 生命周期 / 空闲超时则关闭
161        if meta and should_close(meta, self._max_lifetime, self._idle_timeout, True):
162            self._close_connection(conn)
163            self._semaphore.release()
164            return
165
166        try:
167            with self._lock:
168                self._free.put_nowait(conn)
169            # 放回 free 后释放许可:free 中的连接不再占用并发槽位,
170            # 后续 acquire 会从 free 直接复用(无需再次获取许可)
171            self._semaphore.release()
172        except queue.Full:
173            self._close_connection(conn)
174            self._semaphore.release()
175        finally:
176            self._total_released += 1

归还连接到池中(如已断开/超时则关闭)。

def start_monitor(self) -> None:
237    def start_monitor(self) -> None:
238        """启动后台清理线程(幂等)。"""
239        if self._monitor_thread is not None and self._monitor_thread.is_alive():
240            return
241        self._stop_event.clear()
242        self._monitor_thread = threading.Thread(
243            target=self._monitor_loop,
244            name="sync-connection-pool-monitor",
245            daemon=True,
246        )
247        self._monitor_thread.start()

启动后台清理线程(幂等)。

def stop_monitor(self) -> None:
249    def stop_monitor(self) -> None:
250        """停止后台清理线程。"""
251        self._stop_event.set()
252        if self._monitor_thread and self._monitor_thread.is_alive():
253            self._monitor_thread.join(timeout=2.0)

停止后台清理线程。

def acquire_context(self) -> '_AcquireContext':
289    def acquire_context(self) -> "_AcquireContext":
290        """获取连接的上下文管理器。"""
291        return SyncConnectionPool._AcquireContext(self)

获取连接的上下文管理器。

def close_all(self) -> None:
306    def close_all(self) -> None:
307        """关闭池中所有连接并停止监控。"""
308        self._closed = True
309        self.stop_monitor()
310        with self._lock:
311            conns = list(self._connections)
312        for conn in conns:
313            self._close_connection(conn)
314        # 释放所有信号量
315        while not self._free.empty():
316            self._free.get_nowait()

关闭池中所有连接并停止监控。

class TaskRunner:
 75class TaskRunner:
 76    """
 77    后台任务运行器
 78
 79    支持任务提交、取消、状态查询和等待完成。
 80    使用 threading.Thread 执行后台任务,通过 Semaphore 控制并发。
 81
 82    Args:
 83        max_workers: 最大并发任务数,默认 10
 84    """
 85
 86    def __init__(self, max_workers: int = 10) -> None:
 87        self._max_workers = max_workers
 88        self._tasks: dict[str, Task] = {}
 89        self._events: dict[str, threading.Event] = {}
 90        self._cancel_flags: dict[str, threading.Event] = {}
 91        self._semaphore = threading.Semaphore(max_workers)
 92        self._lock = threading.Lock()
 93
 94    # ========================================================================
 95    # 任务管理
 96    # ========================================================================
 97
 98    def submit(
 99        self,
100        name: str,
101        fn: Callable[..., Any],
102        *args: Any,
103        metadata: Optional[dict[str, Any]] = None,
104        **kwargs: Any,
105    ) -> str:
106        """
107        提交一个后台任务
108
109        Args:
110            name: 任务名称(用于显示和日志)
111            fn: 要执行的函数
112            *args: 函数参数
113            metadata: 任务元数据(可选)
114            **kwargs: 函数关键字参数
115
116        Returns:
117            str: 任务 ID
118
119        Raises:
120            ValueError: 任务名称不能为空
121        """
122        if not name:
123            raise ValueError("task name must not be empty")
124
125        task_id = uuid.uuid4().hex
126        task = Task(
127            id=task_id,
128            name=name,
129            status=TaskStatus.PENDING,
130            created_at=datetime.now(),
131            metadata=metadata or {},
132        )
133
134        with self._lock:
135            self._tasks[task_id] = task
136            self._events[task_id] = threading.Event()
137            self._cancel_flags[task_id] = threading.Event()
138
139        # 获取信号量后再启动线程(限制并发)
140        self._semaphore.acquire()
141
142        # 若在等待信号量期间任务已被取消,放弃启动并归还槽位。
143        # (cancel(PENDING) 只 set cancel_flag 不 release,槽位配对关系:
144        #  submit.acquire() ↔ 本处 release 或 _execute_wrapper.finally)
145        if self._cancel_flags.get(task_id, threading.Event()).is_set():
146            self._semaphore.release()
147            logger.info(f"task cancelled before scheduling: [{task_id[:8]}] {task.name}")
148            return task_id
149
150        thread = threading.Thread(
151            target=self._execute_wrapper,
152            args=(task_id, fn, args, kwargs),
153            daemon=True,
154            name=f"task-{name[:16]}-{task_id[:8]}",
155        )
156        thread.start()
157
158        logger.info(f"task submitted: [{task_id[:8]}] {name}")
159        return task_id
160
161    def cancel(self, task_id: str) -> bool:
162        """
163        取消一个任务
164
165        对于 PENDING 状态的任务直接标记取消。
166        对于 RUNNING 状态的任务设置取消标志(需要函数内部检查)。
167
168        Args:
169            task_id: 任务 ID
170
171        Returns:
172            bool: True if the cancel succeeded
173        """
174        with self._lock:
175            task = self._tasks.get(task_id)
176            if task is None:
177                return False
178
179            if task.status == TaskStatus.PENDING:
180                task.status = TaskStatus.CANCELLED
181                task.completed_at = datetime.now()
182                # 设置取消标志:阻塞在信号量 acquire 的 submit 线程在拿到
183                # 槽位后会检查该标志,发现已取消则放弃启动并归还槽位。
184                # 注意:此处不 release 信号量 —— 槽位配对关系为
185                # submit.acquire() ↔ (_execute_wrapper.finally 或 submit 放弃时)
186                # 的 release,cancel 介入会破坏对称性导致双 release(P0-C)。
187                if task_id in self._cancel_flags:
188                    self._cancel_flags[task_id].set()
189                if task_id in self._events:
190                    self._events[task_id].set()
191                logger.info(f"task cancelled: [{task_id[:8]}] {task.name}")
192                return True
193
194            if task.status == TaskStatus.RUNNING:
195                # 设置取消标志
196                if task_id in self._cancel_flags:
197                    self._cancel_flags[task_id].set()
198                logger.info(f"cancelling task: [{task_id[:8]}] {task.name}")
199                return True
200
201            return False
202
203    def get_task(self, task_id: str) -> Optional[Task]:
204        """
205        获取任务信息
206
207        Args:
208            task_id: 任务 ID
209
210        Returns:
211            Optional[Task]: 任务对象,不存在时返回 None
212        """
213        with self._lock:
214            task = self._tasks.get(task_id)
215            if task is None:
216                return None
217            # 返回副本避免外部修改
218            import dataclasses
219
220            return dataclasses.replace(task)
221
222    def get_status(self, task_id: str) -> Optional[TaskStatus]:
223        """
224        获取任务状态
225
226        Args:
227            task_id: 任务 ID
228
229        Returns:
230            Optional[TaskStatus]: 任务状态,不存在时返回 None
231        """
232        with self._lock:
233            task = self._tasks.get(task_id)
234            return task.status if task else None
235
236    def list_tasks(
237        self,
238        status: Optional[TaskStatus] = None,
239        limit: int = 50,
240    ) -> list[Task]:
241        """
242        列出任务
243
244        Args:
245            status: 按状态筛选(可选)
246            limit: 最大返回数量,默认 50
247
248        Returns:
249            List[Task]: 任务列表(按创建时间降序)
250        """
251        with self._lock:
252            tasks = list(self._tasks.values())
253
254        # 按创建时间降序排序
255        tasks.sort(key=lambda t: t.created_at, reverse=True)
256
257        # 按状态筛选
258        if status:
259            tasks = [t for t in tasks if t.status == status]
260
261        return tasks[:limit]
262
263    def wait_for(self, task_id: str, timeout: Optional[float] = None) -> Task:
264        """
265        等待任务完成
266
267        Args:
268            task_id: 任务 ID
269            timeout: 超时时间(秒),None 表示无限等待
270
271        Returns:
272            Task: 已完成的任务
273
274        Raises:
275            TimeoutError: 等待超时
276            KeyError: 任务不存在
277        """
278        with self._lock:
279            if task_id not in self._events:
280                raise KeyError(f"Task '{task_id[:8]}' not found")
281            event = self._events[task_id]
282
283        if not event.wait(timeout=timeout):
284            raise TimeoutError(f"Timeout waiting for task '{task_id[:8]}'")
285
286        task = self.get_task(task_id)
287        if task is None:
288            raise KeyError(f"Task '{task_id[:8]}' not found")
289        return task
290
291    def cancel_all(self) -> int:
292        """
293        取消所有 PENDING 状态的任务
294
295        Returns:
296            int: 已取消的任务数
297        """
298        count = 0
299        with self._lock:
300            for task_id, task in list(self._tasks.items()):
301                if task.status == TaskStatus.PENDING:
302                    task.status = TaskStatus.CANCELLED
303                    task.completed_at = datetime.now()
304                    # 与 cancel(PENDING) 一致:只设标志不释放信号量。
305                    # 阻塞在 acquire 的 submit 线程拿到槽位后会检查标志,
306                    # 发现已取消则归还槽位并放弃启动(P0-C)。
307                    if task_id in self._cancel_flags:
308                        self._cancel_flags[task_id].set()
309                    if task_id in self._events:
310                        self._events[task_id].set()
311                    count += 1
312
313        if count > 0:
314            logger.info(f"cancelled {count} pending tasks")
315        return count
316
317    def cleanup_old(self, max_age_seconds: int = 3600) -> int:
318        """
319        清理过期任务
320
321        Args:
322            max_age_seconds: 最大保留时间(秒),默认 1 小时
323
324        Returns:
325            int: 已清理的任务数
326        """
327        now = datetime.now()
328        to_remove: list[str] = []
329
330        with self._lock:
331            for task_id, task in list(self._tasks.items()):
332                if (
333                    task.status
334                    in (
335                        TaskStatus.SUCCESS,
336                        TaskStatus.FAILED,
337                        TaskStatus.CANCELLED,
338                    )
339                    and task.completed_at
340                ):
341                    age = (now - task.completed_at).total_seconds()
342                    if age > max_age_seconds:
343                        to_remove.append(task_id)
344
345            for task_id in to_remove:
346                del self._tasks[task_id]
347                self._events.pop(task_id, None)
348                self._cancel_flags.pop(task_id, None)
349
350        if to_remove:
351            logger.debug(f"cleaned up {len(to_remove)} expired tasks")
352        return len(to_remove)
353
354    # ========================================================================
355    # 属性
356    # ========================================================================
357
358    @property
359    def active_count(self) -> int:
360        """当前运行中的任务数"""
361        return self._max_workers - self._semaphore._value  # noqa: SLF001
362
363    @property
364    def pending_count(self) -> int:
365        """当前待处理的任务数"""
366        count = 0
367        with self._lock:
368            for task in self._tasks.values():
369                if task.status == TaskStatus.PENDING:
370                    count += 1
371        return count
372
373    # ========================================================================
374    # 内部方法
375    # ========================================================================
376
377    def _execute_wrapper(
378        self,
379        task_id: str,
380        fn: Callable,
381        args: tuple,
382        kwargs: dict,
383    ) -> None:
384        """
385        任务执行包装器
386
387        负责状态转换、取消检查、异常处理和资源释放。
388        """
389        if self._check_cancelled_before_start(task_id):
390            return
391
392        self._mark_running(task_id)
393
394        try:
395            result = self._run_task_function(task_id, fn, args, kwargs)
396            self._handle_completion(task_id, result)
397        except Exception as e:  # noqa: BLE001
398            self._handle_exception(task_id, e)
399        finally:
400            self._cleanup_resources(task_id)
401
402    def _check_cancelled_before_start(self, task_id: str) -> bool:
403        """检查任务在启动前是否已被取消(竞态窗口处理)。
404
405        Returns:
406            bool: True if task was cancelled and cleanup done, False to continue
407        """
408        if not self._cancel_flags.get(task_id, threading.Event()).is_set():
409            return False
410
411        self._semaphore.release()
412        with self._lock:
413            task = self._tasks.get(task_id)
414            if task and task.status == TaskStatus.PENDING:
415                task.status = TaskStatus.CANCELLED
416                task.completed_at = datetime.now()
417        if task_id in self._events:
418            self._events[task_id].set()
419        return True
420
421    def _mark_running(self, task_id: str) -> None:
422        """将任务标记为运行中状态"""
423        with self._lock:
424            task = self._tasks.get(task_id)
425            if task:
426                task.status = TaskStatus.RUNNING
427                task.started_at = datetime.now()
428
429    def _run_task_function(self, task_id: str, fn: Callable, args: tuple, kwargs: dict) -> Any:
430        """执行任务函数,返回结果"""
431        logger.debug(f"task started: [{task_id[:8]}] running...")
432        return fn(*args, **kwargs)
433
434    def _handle_completion(self, task_id: str, result: Any) -> None:
435        """处理任务正常完成(成功或被取消)"""
436        if self._cancel_flags.get(task_id, threading.Event()).is_set():
437            with self._lock:
438                task = self._tasks.get(task_id)
439                if task:
440                    task.status = TaskStatus.CANCELLED
441                    task.completed_at = datetime.now()
442            logger.info(f"task was cancelled: [{task_id[:8]}]")
443        else:
444            with self._lock:
445                task = self._tasks.get(task_id)
446                if task:
447                    task.result = result
448                    task.status = TaskStatus.SUCCESS
449                    task.completed_at = datetime.now()
450            logger.info(f"task finished: [{task_id[:8]}]")
451
452    def _handle_exception(self, task_id: str, exc: Exception) -> None:
453        """处理任务执行异常"""
454        with self._lock:
455            task = self._tasks.get(task_id)
456            if task and task.status != TaskStatus.CANCELLED:
457                task.error = str(exc)
458                task.status = TaskStatus.FAILED
459                task.completed_at = datetime.now()
460        logger.error(f"task failed: [{task_id[:8]}] {exc}")
461
462    def _cleanup_resources(self, task_id: str) -> None:
463        """清理资源:释放信号量、触发完成事件"""
464        self._semaphore.release()
465        if task_id in self._events:
466            self._events[task_id].set()

后台任务运行器

支持任务提交、取消、状态查询和等待完成。 使用 threading.Thread 执行后台任务,通过 Semaphore 控制并发。

Args: max_workers: 最大并发任务数,默认 10

TaskRunner(max_workers: int = 10)
86    def __init__(self, max_workers: int = 10) -> None:
87        self._max_workers = max_workers
88        self._tasks: dict[str, Task] = {}
89        self._events: dict[str, threading.Event] = {}
90        self._cancel_flags: dict[str, threading.Event] = {}
91        self._semaphore = threading.Semaphore(max_workers)
92        self._lock = threading.Lock()
def submit( self, name: str, fn: Callable[..., Any], *args: Any, metadata: dict[str, Any] | None = None, **kwargs: Any) -> str:
 98    def submit(
 99        self,
100        name: str,
101        fn: Callable[..., Any],
102        *args: Any,
103        metadata: Optional[dict[str, Any]] = None,
104        **kwargs: Any,
105    ) -> str:
106        """
107        提交一个后台任务
108
109        Args:
110            name: 任务名称(用于显示和日志)
111            fn: 要执行的函数
112            *args: 函数参数
113            metadata: 任务元数据(可选)
114            **kwargs: 函数关键字参数
115
116        Returns:
117            str: 任务 ID
118
119        Raises:
120            ValueError: 任务名称不能为空
121        """
122        if not name:
123            raise ValueError("task name must not be empty")
124
125        task_id = uuid.uuid4().hex
126        task = Task(
127            id=task_id,
128            name=name,
129            status=TaskStatus.PENDING,
130            created_at=datetime.now(),
131            metadata=metadata or {},
132        )
133
134        with self._lock:
135            self._tasks[task_id] = task
136            self._events[task_id] = threading.Event()
137            self._cancel_flags[task_id] = threading.Event()
138
139        # 获取信号量后再启动线程(限制并发)
140        self._semaphore.acquire()
141
142        # 若在等待信号量期间任务已被取消,放弃启动并归还槽位。
143        # (cancel(PENDING) 只 set cancel_flag 不 release,槽位配对关系:
144        #  submit.acquire() ↔ 本处 release 或 _execute_wrapper.finally)
145        if self._cancel_flags.get(task_id, threading.Event()).is_set():
146            self._semaphore.release()
147            logger.info(f"task cancelled before scheduling: [{task_id[:8]}] {task.name}")
148            return task_id
149
150        thread = threading.Thread(
151            target=self._execute_wrapper,
152            args=(task_id, fn, args, kwargs),
153            daemon=True,
154            name=f"task-{name[:16]}-{task_id[:8]}",
155        )
156        thread.start()
157
158        logger.info(f"task submitted: [{task_id[:8]}] {name}")
159        return task_id

提交一个后台任务

Args: name: 任务名称(用于显示和日志) fn: 要执行的函数 args: 函数参数 metadata: 任务元数据(可选) *kwargs: 函数关键字参数

Returns: str: 任务 ID

Raises: ValueError: 任务名称不能为空

def cancel(self, task_id: str) -> bool:
161    def cancel(self, task_id: str) -> bool:
162        """
163        取消一个任务
164
165        对于 PENDING 状态的任务直接标记取消。
166        对于 RUNNING 状态的任务设置取消标志(需要函数内部检查)。
167
168        Args:
169            task_id: 任务 ID
170
171        Returns:
172            bool: True if the cancel succeeded
173        """
174        with self._lock:
175            task = self._tasks.get(task_id)
176            if task is None:
177                return False
178
179            if task.status == TaskStatus.PENDING:
180                task.status = TaskStatus.CANCELLED
181                task.completed_at = datetime.now()
182                # 设置取消标志:阻塞在信号量 acquire 的 submit 线程在拿到
183                # 槽位后会检查该标志,发现已取消则放弃启动并归还槽位。
184                # 注意:此处不 release 信号量 —— 槽位配对关系为
185                # submit.acquire() ↔ (_execute_wrapper.finally 或 submit 放弃时)
186                # 的 release,cancel 介入会破坏对称性导致双 release(P0-C)。
187                if task_id in self._cancel_flags:
188                    self._cancel_flags[task_id].set()
189                if task_id in self._events:
190                    self._events[task_id].set()
191                logger.info(f"task cancelled: [{task_id[:8]}] {task.name}")
192                return True
193
194            if task.status == TaskStatus.RUNNING:
195                # 设置取消标志
196                if task_id in self._cancel_flags:
197                    self._cancel_flags[task_id].set()
198                logger.info(f"cancelling task: [{task_id[:8]}] {task.name}")
199                return True
200
201            return False

取消一个任务

对于 PENDING 状态的任务直接标记取消。 对于 RUNNING 状态的任务设置取消标志(需要函数内部检查)。

Args: task_id: 任务 ID

Returns: bool: True if the cancel succeeded

def get_task(self, task_id: str) -> Task | None:
203    def get_task(self, task_id: str) -> Optional[Task]:
204        """
205        获取任务信息
206
207        Args:
208            task_id: 任务 ID
209
210        Returns:
211            Optional[Task]: 任务对象,不存在时返回 None
212        """
213        with self._lock:
214            task = self._tasks.get(task_id)
215            if task is None:
216                return None
217            # 返回副本避免外部修改
218            import dataclasses
219
220            return dataclasses.replace(task)

获取任务信息

Args: task_id: 任务 ID

Returns: Optional[Task]: 任务对象,不存在时返回 None

def get_status(self, task_id: str) -> TaskStatus | None:
222    def get_status(self, task_id: str) -> Optional[TaskStatus]:
223        """
224        获取任务状态
225
226        Args:
227            task_id: 任务 ID
228
229        Returns:
230            Optional[TaskStatus]: 任务状态,不存在时返回 None
231        """
232        with self._lock:
233            task = self._tasks.get(task_id)
234            return task.status if task else None

获取任务状态

Args: task_id: 任务 ID

Returns: Optional[TaskStatus]: 任务状态,不存在时返回 None

def list_tasks( self, status: TaskStatus | None = None, limit: int = 50) -> list[Task]:
236    def list_tasks(
237        self,
238        status: Optional[TaskStatus] = None,
239        limit: int = 50,
240    ) -> list[Task]:
241        """
242        列出任务
243
244        Args:
245            status: 按状态筛选(可选)
246            limit: 最大返回数量,默认 50
247
248        Returns:
249            List[Task]: 任务列表(按创建时间降序)
250        """
251        with self._lock:
252            tasks = list(self._tasks.values())
253
254        # 按创建时间降序排序
255        tasks.sort(key=lambda t: t.created_at, reverse=True)
256
257        # 按状态筛选
258        if status:
259            tasks = [t for t in tasks if t.status == status]
260
261        return tasks[:limit]

列出任务

Args: status: 按状态筛选(可选) limit: 最大返回数量,默认 50

Returns: List[Task]: 任务列表(按创建时间降序)

def wait_for( self, task_id: str, timeout: float | None = None) -> Task:
263    def wait_for(self, task_id: str, timeout: Optional[float] = None) -> Task:
264        """
265        等待任务完成
266
267        Args:
268            task_id: 任务 ID
269            timeout: 超时时间(秒),None 表示无限等待
270
271        Returns:
272            Task: 已完成的任务
273
274        Raises:
275            TimeoutError: 等待超时
276            KeyError: 任务不存在
277        """
278        with self._lock:
279            if task_id not in self._events:
280                raise KeyError(f"Task '{task_id[:8]}' not found")
281            event = self._events[task_id]
282
283        if not event.wait(timeout=timeout):
284            raise TimeoutError(f"Timeout waiting for task '{task_id[:8]}'")
285
286        task = self.get_task(task_id)
287        if task is None:
288            raise KeyError(f"Task '{task_id[:8]}' not found")
289        return task

等待任务完成

Args: task_id: 任务 ID timeout: 超时时间(秒),None 表示无限等待

Returns: Task: 已完成的任务

Raises: TimeoutError: 等待超时 KeyError: 任务不存在

def cancel_all(self) -> int:
291    def cancel_all(self) -> int:
292        """
293        取消所有 PENDING 状态的任务
294
295        Returns:
296            int: 已取消的任务数
297        """
298        count = 0
299        with self._lock:
300            for task_id, task in list(self._tasks.items()):
301                if task.status == TaskStatus.PENDING:
302                    task.status = TaskStatus.CANCELLED
303                    task.completed_at = datetime.now()
304                    # 与 cancel(PENDING) 一致:只设标志不释放信号量。
305                    # 阻塞在 acquire 的 submit 线程拿到槽位后会检查标志,
306                    # 发现已取消则归还槽位并放弃启动(P0-C)。
307                    if task_id in self._cancel_flags:
308                        self._cancel_flags[task_id].set()
309                    if task_id in self._events:
310                        self._events[task_id].set()
311                    count += 1
312
313        if count > 0:
314            logger.info(f"cancelled {count} pending tasks")
315        return count

取消所有 PENDING 状态的任务

Returns: int: 已取消的任务数

def cleanup_old(self, max_age_seconds: int = 3600) -> int:
317    def cleanup_old(self, max_age_seconds: int = 3600) -> int:
318        """
319        清理过期任务
320
321        Args:
322            max_age_seconds: 最大保留时间(秒),默认 1 小时
323
324        Returns:
325            int: 已清理的任务数
326        """
327        now = datetime.now()
328        to_remove: list[str] = []
329
330        with self._lock:
331            for task_id, task in list(self._tasks.items()):
332                if (
333                    task.status
334                    in (
335                        TaskStatus.SUCCESS,
336                        TaskStatus.FAILED,
337                        TaskStatus.CANCELLED,
338                    )
339                    and task.completed_at
340                ):
341                    age = (now - task.completed_at).total_seconds()
342                    if age > max_age_seconds:
343                        to_remove.append(task_id)
344
345            for task_id in to_remove:
346                del self._tasks[task_id]
347                self._events.pop(task_id, None)
348                self._cancel_flags.pop(task_id, None)
349
350        if to_remove:
351            logger.debug(f"cleaned up {len(to_remove)} expired tasks")
352        return len(to_remove)

清理过期任务

Args: max_age_seconds: 最大保留时间(秒),默认 1 小时

Returns: int: 已清理的任务数

active_count: int
358    @property
359    def active_count(self) -> int:
360        """当前运行中的任务数"""
361        return self._max_workers - self._semaphore._value  # noqa: SLF001

当前运行中的任务数

pending_count: int
363    @property
364    def pending_count(self) -> int:
365        """当前待处理的任务数"""
366        count = 0
367        with self._lock:
368            for task in self._tasks.values():
369                if task.status == TaskStatus.PENDING:
370                    count += 1
371        return count

当前待处理的任务数

@dataclass
class Task:
47@dataclass
48class Task:
49    """
50    任务数据类
51
52    Attributes:
53        id: 任务 ID(UUID)
54        name: 任务名称
55        status: 任务状态
56        created_at: 创建时间
57        started_at: 开始时间
58        completed_at: 完成时间
59        result: 任务结果
60        error: 错误信息
61        metadata: 附加元数据
62    """
63
64    id: str
65    name: str
66    status: TaskStatus
67    created_at: datetime
68    started_at: Optional[datetime] = None
69    completed_at: Optional[datetime] = None
70    result: Any = None
71    error: Optional[str] = None
72    metadata: dict[str, Any] = field(default_factory=dict)

任务数据类

Attributes: id: 任务 ID(UUID) name: 任务名称 status: 任务状态 created_at: 创建时间 started_at: 开始时间 completed_at: 完成时间 result: 任务结果 error: 错误信息 metadata: 附加元数据

Task( id: str, name: str, status: TaskStatus, created_at: datetime.datetime, started_at: datetime.datetime | None = None, completed_at: datetime.datetime | None = None, result: Any = None, error: str | None = None, metadata: dict[str, typing.Any] = <factory>)
id: str
name: str
status: TaskStatus
created_at: datetime.datetime
started_at: datetime.datetime | None = None
completed_at: datetime.datetime | None = None
result: Any = None
error: str | None = None
metadata: dict[str, typing.Any]
class TaskStatus(builtins.str, enum.Enum):
34class TaskStatus(str, Enum):
35    """任务状态枚举"""
36
37    PENDING = "PENDING"
38    RUNNING = "RUNNING"
39    SUCCESS = "SUCCESS"
40    FAILED = "FAILED"
41    CANCELLED = "CANCELLED"
42
43    def __str__(self) -> str:
44        return self.value

任务状态枚举

PENDING = <TaskStatus.PENDING: 'PENDING'>
RUNNING = <TaskStatus.RUNNING: 'RUNNING'>
SUCCESS = <TaskStatus.SUCCESS: 'SUCCESS'>
FAILED = <TaskStatus.FAILED: 'FAILED'>
CANCELLED = <TaskStatus.CANCELLED: 'CANCELLED'>
class KeyringCredentialProvider(remote_cmd.CredentialProvider):
142class KeyringCredentialProvider(CredentialProvider):
143    """
144    Keyring 凭据提供者
145
146    使用系统 Keyring 服务(Windows Credential Manager / macOS Keychain / Linux Secret Service)
147    获取密码。需要安装 keyring 库(pip install keyring)。
148
149    在凭据链中的位置:EnvCredentialProvider 之后,EncryptedFileCredentialProvider 之前。
150
151    Args:
152        service_name: Keyring 服务名称,默认 "remote-cmd"
153    """
154
155    def __init__(self, service_name: str = "remote-cmd") -> None:
156        self._service_name = service_name
157
158    def get_password(self, host: Host) -> Optional[str]:
159        """
160        从系统 Keyring 获取密码
161
162        使用 keyring.get_password(service_name, host.name) 获取。
163        keyring 库为可选依赖,未安装时静默返回 None。
164
165        Args:
166            host: 主机配置对象
167
168        Returns:
169            Optional[str]: 密码,未找到或不可用时返回 None
170        """
171        try:
172            import keyring
173
174            password = keyring.get_password(self._service_name, host.name)
175            if password:
176                logger.debug(f"retrieved password for {host.name} ")
177            return password
178        except ImportError:
179            logger.debug("keyring not installed, skipping KeyringCredentialProvider")
180            return None
181        except Exception as e:  # noqa: BLE001
182            logger.debug(f"keyring access failed: {e}")
183            return None
184
185    def set_password(self, host: Host, password: str) -> bool:
186        """
187        向 Keyring 存储密码
188
189        Args:
190            host: 主机配置对象
191            password: to store
192
193        Returns:
194            bool: True if the store succeeded
195        """
196        try:
197            import keyring
198
199            keyring.set_password(self._service_name, host.name, password)
200            return True
201        except Exception as e:  # noqa: BLE001
202            logger.debug(f"keyring store failed: {e}")
203            return False
204
205    def delete_password(self, host: Host) -> bool:
206        """
207        从 Keyring 删除密码
208
209        Args:
210            host: 主机配置对象
211
212        Returns:
213            bool: True if the delete succeeded
214        """
215        try:
216            import keyring
217
218            keyring.delete_password(self._service_name, host.name)
219            return True
220        except Exception as e:  # noqa: BLE001
221            logger.debug(f"keyring delete failed: {e}")
222            return False

Keyring 凭据提供者

使用系统 Keyring 服务(Windows Credential Manager / macOS Keychain / Linux Secret Service) 获取密码。需要安装 keyring 库(pip install keyring)。

在凭据链中的位置:EnvCredentialProvider 之后,EncryptedFileCredentialProvider 之前。

Args: service_name: Keyring 服务名称,默认 "remote-cmd"

KeyringCredentialProvider(service_name: str = 'remote-cmd')
155    def __init__(self, service_name: str = "remote-cmd") -> None:
156        self._service_name = service_name
def get_password(self, host: Host) -> str | None:
158    def get_password(self, host: Host) -> Optional[str]:
159        """
160        从系统 Keyring 获取密码
161
162        使用 keyring.get_password(service_name, host.name) 获取。
163        keyring 库为可选依赖,未安装时静默返回 None。
164
165        Args:
166            host: 主机配置对象
167
168        Returns:
169            Optional[str]: 密码,未找到或不可用时返回 None
170        """
171        try:
172            import keyring
173
174            password = keyring.get_password(self._service_name, host.name)
175            if password:
176                logger.debug(f"retrieved password for {host.name} ")
177            return password
178        except ImportError:
179            logger.debug("keyring not installed, skipping KeyringCredentialProvider")
180            return None
181        except Exception as e:  # noqa: BLE001
182            logger.debug(f"keyring access failed: {e}")
183            return None

从系统 Keyring 获取密码

使用 keyring.get_password(service_name, host.name) 获取。 keyring 库为可选依赖,未安装时静默返回 None。

Args: host: 主机配置对象

Returns: Optional[str]: 密码,未找到或不可用时返回 None

def set_password(self, host: Host, password: str) -> bool:
185    def set_password(self, host: Host, password: str) -> bool:
186        """
187        向 Keyring 存储密码
188
189        Args:
190            host: 主机配置对象
191            password: to store
192
193        Returns:
194            bool: True if the store succeeded
195        """
196        try:
197            import keyring
198
199            keyring.set_password(self._service_name, host.name, password)
200            return True
201        except Exception as e:  # noqa: BLE001
202            logger.debug(f"keyring store failed: {e}")
203            return False

向 Keyring 存储密码

Args: host: 主机配置对象 password: to store

Returns: bool: True if the store succeeded

def delete_password(self, host: Host) -> bool:
205    def delete_password(self, host: Host) -> bool:
206        """
207        从 Keyring 删除密码
208
209        Args:
210            host: 主机配置对象
211
212        Returns:
213            bool: True if the delete succeeded
214        """
215        try:
216            import keyring
217
218            keyring.delete_password(self._service_name, host.name)
219            return True
220        except Exception as e:  # noqa: BLE001
221            logger.debug(f"keyring delete failed: {e}")
222            return False

从 Keyring 删除密码

Args: host: 主机配置对象

Returns: bool: True if the delete succeeded

__version__ = '2.1.0'
__author__ = 'Vae-Scrooge'
__license__ = 'MIT'