Files
cursor_ai/modules/ssh_base.py

218 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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