#!/usr/bin/python3 # -*- coding: utf-8 -*- """ Базовый модуль для SSH подключений """ import paramiko import os import time import logging from typing import List from .logger import get_logger logger = get_logger("ssh_base") # Ожидаемые «ошибки» ZFS при бэкапе — не логируем WARNING при suppress_warnings=True KNOWN_ZFS_ERRORS = ( "dataset already exists", "not an earlier snapshot from the same fs", ) class SSHBase: """ Базовый класс для SSH подключений """ def __init__(self, hostname: str = "test", port: int = 22222, username: str = "root", pkey_file: str = "/root/.ssh/id_rsa", host_keys: str = "~/.ssh/known_hosts") -> None: """ Инициализация SSH клиента Args: hostname: Имя хоста или IP адрес port: Порт SSH (int) username: Имя пользователя pkey_file: Путь к приватному ключу host_keys: Путь к файлу known_hosts Raises: ValueError: При невалидных входных данных FileNotFoundError: При отсутствии файла ключа paramiko.ssh_exception.SSHException: При ошибке инициализации """ # Валидация входных данных if not hostname or not isinstance(hostname, str) or not hostname.strip(): raise ValueError("hostname должен быть непустой строкой") try: self.port = int(port) if not (1 <= self.port <= 65535): raise ValueError("port должен быть в диапазоне 1-65535") except (ValueError, TypeError): raise ValueError(f"port должен быть целым числом, получено: {type(port).__name__}") if not username or not isinstance(username, str) or not username.strip(): raise ValueError("username должен быть непустой строкой") if not pkey_file or not isinstance(pkey_file, str): raise ValueError("pkey_file должен быть непустой строкой") if not os.path.exists(pkey_file): raise FileNotFoundError(f"Файл приватного ключа не найден: {pkey_file}") self.hostname = hostname.strip() self.user = username.strip() self.host_keys = host_keys self.s = None self.stdin = None self.stdout = None self.stderr = None try: logger.info(f"Инициализация SSH клиента для {hostname}:{port}") self.key = paramiko.RSAKey.from_private_key_file(pkey_file) self.s = paramiko.SSHClient() self.s.load_system_host_keys() if host_keys: expanded_host_keys = os.path.expanduser(host_keys) if os.path.exists(expanded_host_keys): self.s.load_host_keys(expanded_host_keys) self.s.set_missing_host_key_policy(paramiko.AutoAddPolicy()) logger.debug("SSH клиент успешно инициализирован") except FileNotFoundError as e: logger.error(f"Не найден файл ключа или known_hosts: {e}") raise FileNotFoundError(f"Не найден файл ключа или known_hosts: {e}") except paramiko.ssh_exception.SSHException as e: logger.error(f"Ошибка инициализации SSH клиента: {e}") raise paramiko.ssh_exception.SSHException(f"Ошибка инициализации SSH клиента: {e}") def connect(self) -> None: """ Подключение к SSH серверу Raises: paramiko.ssh_exception.SSHException: При ошибке подключения paramiko.ssh_exception.AuthenticationException: При ошибке аутентификации """ if not self.s: logger.error("SSH клиент не инициализирован") raise paramiko.ssh_exception.SSHException("SSH клиент не инициализирован") try: logger.info(f"Подключение к {self.hostname}:{self.port}") self.s.connect(self.hostname, self.port, pkey=self.key, username=self.user) logger.info(f"Успешно подключено к {self.hostname}:{self.port}") except paramiko.ssh_exception.AuthenticationException as e: logger.error(f"Ошибка аутентификации при подключении к {self.hostname}:{self.port}: {e}") raise paramiko.ssh_exception.AuthenticationException(f"Ошибка аутентификации: {e}") except paramiko.ssh_exception.SSHException as e: logger.error(f"Ошибка подключения к {self.hostname}:{self.port}: {e}") raise paramiko.ssh_exception.SSHException(f"Ошибка подключения к {self.hostname}:{self.port}: {e}") except Exception as e: logger.error(f"Неожиданная ошибка при подключении к {self.hostname}:{self.port}: {e}") raise Exception(f"Неожиданная ошибка при подключении: {e}") def cmd(self, command: str, sleep: float = 0.1, out_to_print: bool = False, suppress_warnings: bool = False) -> List[str]: """ Выполнение команды на удаленном сервере Args: command: Команда для выполнения sleep: Задержка после выполнения команды (секунды) out_to_print: Не используется (оставлено для обратной совместимости) suppress_warnings: Если True и stderr содержит ожидаемые ZFS-сообщения (KNOWN_ZFS_ERRORS), не логировать WARNING (для чистых логов бэкапа). Returns: list: [stdout, stderr] - вывод команды и ошибки Raises: ValueError: При невалидных входных данных paramiko.ssh_exception.SSHException: При ошибке выполнения команды """ # Валидация входных данных if not command or not isinstance(command, str) or not command.strip(): raise ValueError("command должен быть непустой строкой") if not isinstance(sleep, (int, float)) or sleep < 0: raise ValueError("sleep должен быть неотрицательным числом") if not self.s: logger.error("SSH клиент не подключен") raise paramiko.ssh_exception.SSHException("SSH клиент не подключен. Вызовите connect() перед выполнением команд.") try: logger.debug(f"Выполнение команды: {command}") self.stdin, self.stdout, self.stderr = self.s.exec_command(command) # Ждем завершения команды и получаем код возврата exit_status = self.stdout.channel.recv_exit_status() time.sleep(sleep) stdout_data = self.stdout.read().decode('utf-8', errors='replace') stderr_data = self.stderr.read().decode('utf-8', errors='replace') # Если команда завершилась с ошибкой, добавляем информацию в stderr if exit_status != 0: skip_warning = suppress_warnings and any( err in stderr_data.lower() for err in KNOWN_ZFS_ERRORS ) if not skip_warning: logger.warning(f"Команда завершилась с кодом возврата {exit_status}: {command}") if not stderr_data.strip(): stderr_data = f"Команда завершилась с кодом возврата {exit_status}" else: logger.debug(f"Команда выполнена успешно: {command}") return [stdout_data, stderr_data] except paramiko.ssh_exception.SSHException as e: logger.error(f"Ошибка выполнения команды '{command}': {e}") raise paramiko.ssh_exception.SSHException(f"Ошибка выполнения команды '{command}': {e}") except Exception as e: logger.error(f"Неожиданная ошибка при выполнении команды '{command}': {e}") raise Exception(f"Неожиданная ошибка при выполнении команды '{command}': {e}") def close(self) -> None: """ Закрытие SSH соединения Безопасно закрывает все открытые соединения и потоки, игнорируя ошибки, которые могут возникнуть при закрытии уже закрытых соединений. """ logger.info(f"Закрытие SSH соединения с {self.hostname}:{self.port}") # Закрываем потоки в правильном порядке streams_to_close = [ ('stdin', self.stdin), ('stdout', self.stdout), ('stderr', self.stderr) ] for name, stream in streams_to_close: if stream is not None: try: if hasattr(stream, 'close'): stream.close() except (AttributeError, OSError, paramiko.ssh_exception.SSHException): # Игнорируем ошибки при закрытии потоков pass finally: setattr(self, name, None) # Закрываем SSH соединение if self.s is not None: try: self.s.close() except (AttributeError, OSError, paramiko.ssh_exception.SSHException): # Игнорируем ошибки при закрытии соединения pass finally: self.s = None