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 ]
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)
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() 方法或使用上下文管理器
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")
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 连接,释放所有相关资源。 即使连接已断开或出现错误,此方法也能安全执行。
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
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)
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")
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")
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")
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")
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
创建远程目录(支持递归创建)
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
删除远程文件
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
删除远程目录
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
检查远程文件是否存在
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
获取远程文件信息
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 连接 并发复用同一事件循环。
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
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: 连接/认证失败时抛出,包含原因映射
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 资源。即使连接已断开也能安全调用。
166 def is_connected(self) -> bool: 167 """检查连接是否处于活动状态。""" 168 return self._conn is not None and not self._conn.is_closed()
检查连接是否处于活动状态。
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: 未连接时抛出
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 传入,不进入进程列表)。
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")
异步上传本地文件到远程服务器。
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")
异步从远程服务器下载文件到本地。
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 一致)。
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: 后台清理任务周期(秒)
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 对齐)
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 }
获取连接池指标快照。
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 之后)
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
归还连接到池中(如已断开/超时则关闭)。
300 def acquire_context(self) -> "_AcquireContext": 301 """获取连接的上下文管理器。""" 302 return AsyncConnectionPool._AcquireContext(self)
获取连接的上下文管理器。
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()
关闭池中所有连接并停止监控。
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; 适合一次性脚本。
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
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 为空
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"] ... )
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 的连接配置对象
77 def to_dict(self) -> dict: 78 """ 79 将主机配置转换为字典 80 81 Returns: 82 Dict: 包含所有主机属性的字典 83 """ 84 return asdict(self)
将主机配置转换为字典
Returns: Dict: 包含所有主机属性的字典
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: 主机配置对象
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: 不包含敏感明文的主机信息字典
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 替代
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: 配置文件路径(可选)
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 的路径。
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()
从文件加载配置
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 兼容)
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 兼容)
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 ...
主机配置仓库抽象基类
35 @abstractmethod 36 def list(self, tag: Optional[str] = None) -> list[Host]: 37 """列出主机,可选按标签筛选""" 38 ...
列出主机,可选按标签筛选
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)
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()
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。
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
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
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
列出主机,可选按标签筛选
103 def flush(self) -> None: 104 """原子写入 JSON 文件""" 105 data = self._serialize_hosts() 106 self._atomic_write(data)
原子写入 JSON 文件
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 连接服务(可选,自动创建)
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 )
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: 同名主机已存在
108 def get_host(self, name: str) -> Host: 109 """获取主机配置(密码自动解密)""" 110 host = self._repo.get(name) 111 return self._decrypt_host(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: 更新后的主机
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}")
删除主机
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]
列出主机(密码自动解密)
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: 已连接的客户端
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
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
并行测试所有主机连接
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)
获取主机并尝试解密密码
解密优先级:
- 通过凭据提供链(环境变量 / keyring / 加密文件存储等)获取明文
- 若凭据链未命中,则回退到本地 CredentialEncryption 解密存储中的加密 token
这一层兜底是必需的:CLI 的默认凭据链可能不包含 EncryptedFileCredentialProvider,但主机密码已被 add_host 加密落盘, 若不兜底解密则 connect_to_host 会拿到加密 token 当密码使用,必然认证失败。
Note: 本方法返回一个新的 Host 副本,绝不就地修改仓库内存中存储的对象。 若直接修改 repo.get() 返回的原始引用,解密后的明文密码会污染内存 中的加密 token,后续任意 add_host/update_host/remove_host 触发 flush() 时明文密码会被写入磁盘,造成凭据泄露。
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 连接服务
提供连接管理、命令执行和健康检查功能。 支持重试和超时控制。
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: 连接失败
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
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: 命令执行结果
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 ...
凭据提供者抽象基类
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 或容器环境。 优先级低于交互式输入但高于默认值。
支持两种查找方式(按优先级):
- 主机专属变量
<env_var>_<HOST>(主机名大写,非字母数字替换为_) - 全局变量
<env_var>
示例:
- 全局:REMOTE_CMD_PASSWORD=secret 对所有主机生效
- 专属:REMOTE_CMD_PASSWORD_WEB1=secret 仅对名为 web1 的主机生效
Args: env_var: 环境变量名(默认 REMOTE_CMD_PASSWORD)
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
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: 凭据提供者列表,按优先级降序排列
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
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: 密钥文件存储路径
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
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: 加密失败
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: 解密失败或格式错误
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
检查字符串是否为加密格式
采用双重校验降低格式碰撞风险:
- 前缀匹配
$encrypted$ - token 部分必须是 Fernet 格式(version byte 0x80,base64 编码后以
g开头, 且解码后总长度至少 57 字节 = 1 + 8 + 16 + 32)
这避免了与真实密码恰好以 $encrypted$ 开头时被误判的场景:
真实密码很少以 $encrypted$g 开头,且 base64 解码长度必须 >= 57 字节。
Attention: 本函数不验证 HMAC/签名,仅为廉价标识符检测;
完整合法性校验在 decrypt() 中完成。
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: 是否启用详细格式
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 方法,在日志记录发出前替换敏感字段。
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.
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
递归脱敏字典中的敏感字段
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("测试连接")
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。
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)
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()
保存或更新主机
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)
按名称获取主机
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")
按名称删除主机
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]
列出主机,可选按标签筛选
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
检查主机是否存在
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
返回主机数量
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 日志
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]: 匹配的主机列表
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]: (主机列表, 总数)
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 始终为同步接口,返回类型一致,
便于上层无差别切换。
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 )
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 为空
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: 按主机名索引的详细结果
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)
73 @property 74 def failed_hosts(self) -> list[str]: 75 """失败主机列表""" 76 return [h for h, r in self.results.items() if not r.success]
失败主机列表
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: 错误信息(如果有)
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: 后台清理线程周期(秒)
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] = {}
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 }
获取连接池指标快照。
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 之后)
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
归还连接到池中(如已断开/超时则关闭)。
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()
启动后台清理线程(幂等)。
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)
停止后台清理线程。
289 def acquire_context(self) -> "_AcquireContext": 290 """获取连接的上下文管理器。""" 291 return SyncConnectionPool._AcquireContext(self)
获取连接的上下文管理器。
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()
关闭池中所有连接并停止监控。
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
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()
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: 任务名称不能为空
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
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
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
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]: 任务列表(按创建时间降序)
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: 任务不存在
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: 已取消的任务数
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: 已清理的任务数
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: 附加元数据
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
任务状态枚举
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"
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
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
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