226 lines
11 KiB
Python
226 lines
11 KiB
Python
#!/usr/bin/python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Базовый модуль для SSH подключений
|
||
"""
|
||
import paramiko
|
||
import os
|
||
import time
|
||
import logging
|
||
from typing import List
|
||
|
||
# Настройка логирования
|
||
logger = logging.getLogger(__name__)
|
||
if not logger.handlers:
|
||
handler = logging.StreamHandler()
|
||
formatter = logging.Formatter(
|
||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||
datefmt='%Y-%m-%d %H:%M:%S'
|
||
)
|
||
handler.setFormatter(formatter)
|
||
logger.addHandler(handler)
|
||
logger.setLevel(logging.INFO)
|
||
|
||
# Ожидаемые «ошибки» 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
|
||
|
||
|
||
|
||
|
||
|