diff --git a/.gitignore b/.gitignore index 61e28ef..4dc4d7c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ lib/ lib64 include/ __pycache__/ +logs/ diff --git a/README.md b/README.md index 6a8d014..f2398ae 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,92 @@ -# Проект: Автоматизация задач с использованием Cursor AI +# SSH Client — автоматизация миграции 1С, ZFS Backup -![Логотип](https://ru.wikipedia.org/wiki/%D0%A4%D0%B0%D0%B9%D0%BB:Cursor_logo.svg) +Централизованное управление удалёнными серверами через SSH: миграция 1С, PostgreSQL, ZFS Backup. -> Краткое описание проекта +## Установка + +```bash +pip install -r requirements.txt +# или (с pyproject.toml): +pip install -e . +``` + +## Конфигурация + +Все конфиги в каталоге `config/` (YAML): + +| Файл | Назначение | +|------------------------|--------------------------------------| +| `config/migration.yaml` | SSH, PostgreSQL, 1С, migration | +| `config/zfs_backup.yaml` | ZFS Backup (серверы, пулы) | +| `config/config_log.yaml` | Логирование (файл, Telegram, PRD v1.8) | + +## Архитектура + +``` +config/ +├── migration.yaml # Миграция 1С, примеры +├── zfs_backup.yaml # ZFS Backup +├── config_log.yaml # Логирование (файл, Telegram) +modules/ +├── ssh_base.py # SSHBase — базовые SSH операции (connect, cmd, close) +├── ssh.py # SSHClient — объединённый клиент (SSH + Postgres + 1C) +├── logger.py # Единый логгер проекта +├── protocols.py # SSHProtocol, SSHOperationsBase — контракты модулей +├── postgresql.py # PostgreSQLOperations — операции с PostgreSQL +├── c1_cluster.py # C1ClusterOperations — операции с кластером 1С +└── zfs_backup_ops.py # ZFS Backup — снапшоты, репликация, очистка +``` + +**Паттерн использования:** +```python +from modules import SSHClient + +client = SSHClient(hostname="host", port=22222, ...) +client.connect() +# PostgreSQL +bases = client.bases_list(srv_pgsql) +# 1С +client.set_c1_config(lxc_name, user, password) +client.base_info_update(...) +client.close() +``` + +## Сценарии + +| Сценарий | Точка входа | Описание | +|------------|----------------------|-----------------------------------| +| Миграция 1С| `1c-migration.py` | Скрипт миграции баз | +| PostgreSQL | `example_postgresql.py` | Примеры: список баз, бэкап, restore | +| Кластер 1С | `example_c1_cluster.py` | Примеры: версия, базы, base_info_update | +| ZFS Backup | `zfs_backup.py` | Cron (ежедневно 20:00), CLI | + +## ZFS Backup + +Конфигурация: `config/zfs_backup.yaml` (формат v1.1 с `servers[].pools[]`). + +```bash +python zfs_backup.py --config=config/zfs_backup.yaml +# или (по умолчанию): +python zfs_backup.py +# после pip install -e .: +zfs-backup +``` + +Логи: настраиваются в `config/config_log.yaml` (по умолчанию `/var/log/zfs_backup.log`). +Telegram: при `telegram.enabled: true` логи дублируются в Telegram-группу (PRD v1.8). + +## Рекомендации + +- **SSHClient** — рекомендуемый класс (PEP8). **ssh** — alias для обратной совместимости. +- Конфиги в `config/`: `migration.yaml` (миграция, примеры), `zfs_backup.yaml` (ZFS). +- Новые операционные модули: наследовать от `SSHOperationsBase`, реализовать контракт `ssh: SSHProtocol`. + +## Git -## 🚀 Работа с GIT: ```bash -lxc shell code cd /root/lib/ssh_client -source bin/activate git status git add . -git commit -a -m 'Реструктуризировал проект' +git commit -a -m 'Сообщение' git push -u origin main -exit +``` diff --git a/config.py b/config.py deleted file mode 100644 index af1a5bf..0000000 --- a/config.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -""" -Конфигурационный файл для SSH клиента и миграции 1С -ВНИМАНИЕ: Не коммитьте этот файл в публичные репозитории! -Пароли хранятся в открытом виде в этом файле. -""" - -# SSH настройки -SSH_CONFIG = { - "hostname": "g.it.cln.su", - "port": 22222, # Порт должен быть int - "username": "root", - "pkey_file": "/root/.ssh/id_rsa", - "host_keys": "~/.ssh/known_hosts" -} - -# PostgreSQL настройки -POSTGRESQL_CONFIG = { - "archive_server": "1c.it.cln.su", - "restore_server": "postgres.it.cln.su", - "backup_date": "16.12.2025", - "extra_backup": True, - "postgres_user": "postgres", - "postgres_password": "PrestigePostgres" -} - -# 1C настройки -C1_CONFIG = { - "lxc_container_name": "c1", # Имя LXC контейнера с сервером 1С - "cluster_user": "neon", - "cluster_password": "Pre$tige310582", - - # Настройки для обновления базы 1С (используются в c1_base_info_update) -# "db_server": "/tmp", -# "db_user": "usr1cv8", -# "db_password": "", - - "db_server": "postgres.it.cln.su", - "db_user": "postgres", - "db_password": "PrestigePostgres", - -# "db_name": "", # Имя базы данных PostgreSQL (если пустое, будет использовано имя базы 1С) - - "infobase_user": "neon", - "infobase_password": "$F%G^H&J*K" -} - -# Списки баз данных для миграции и примеров использования -MIGRATION_CONFIG = { - "archive_bases_name": [ -# 'konsaltpt-buhg', -# 'vpr-ut-crm', -# 'quant-ut', -# 'kompromis-test', -# 'luna-ut', -# 'messinia-buhg', -# 'morea-buhg', -# 'horen-ut', - 'salon', - 'lmotor-ut', - 'staretail', - 'uran-ut', - ], - "restore_bases_name": None, # Если None, будет использован archive_bases_name - "bases": None, # Список баз для обработки в примерах (example_c1_cluster.py, example_postgresql.py) - # Если None, будет использован archive_bases_name - "scheduled_jobs_deny": "on", # Запрет запланированных заданий для всех баз (on/off) - "sessions_deny": "off" # Запрет сеансов для всех баз (on/off) -} - -def get_config(): - """ - Возвращает конфигурацию проекта - - Returns: - dict: Словарь с конфигурацией, содержащий секции: - - ssh: настройки SSH подключения - - postgresql: настройки PostgreSQL - - c1: настройки 1С кластера - - migration: настройки миграции баз данных (включая список баз для примеров) - """ - config = { - 'ssh': SSH_CONFIG.copy(), - 'postgresql': POSTGRESQL_CONFIG.copy(), - 'c1': C1_CONFIG.copy(), - 'migration': MIGRATION_CONFIG.copy() - } - - # Если restore_bases_name не указан, используем archive_bases_name - if config['migration']['restore_bases_name'] is None: - config['migration']['restore_bases_name'] = config['migration']['archive_bases_name'].copy() - - # Если bases не указан, используем archive_bases_name для примеров - if config['migration']['bases'] is None: - config['migration']['bases'] = config['migration']['archive_bases_name'].copy() - - # Валидация scheduled_jobs_deny и sessions_deny - scheduled_jobs_deny = config['migration'].get('scheduled_jobs_deny', 'off') - sessions_deny = config['migration'].get('sessions_deny', 'off') - - if scheduled_jobs_deny not in ['on', 'off']: - raise ValueError(f"scheduled_jobs_deny должен быть 'on' или 'off', получено: {scheduled_jobs_deny}") - if sessions_deny not in ['on', 'off']: - raise ValueError(f"sessions_deny должен быть 'on' или 'off', получено: {sessions_deny}") - - # Устанавливаем значения по умолчанию, если не указаны - config['migration']['scheduled_jobs_deny'] = scheduled_jobs_deny - config['migration']['sessions_deny'] = sessions_deny - - return config - diff --git a/config.yaml b/config.yaml deleted file mode 100644 index 6d6e6c6..0000000 --- a/config.yaml +++ /dev/null @@ -1,35 +0,0 @@ -# Конфигурация ZFS Backup v1.1 -# Запуск: python zfs_backup.py --config=config.yaml - -# Параметры SSH по умолчанию (опционально) -ssh_defaults: - port: 22222 - username: root - pkey_file: /root/.ssh/id_rsa - host_keys: "~/.ssh/known_hosts" - -# Список серверов для бэкапа -servers: - - name: gwo2.mps.cln.su - pools: - - source_pool: zp2 - datasets: - - containers/smb2 - target_pool: fast-backup - - source_pool: zp0 - datasets: - - containers/www - - containers/voip - target_pool: fast-backup - snapshot_name: dd-mm-yyyy - retention_days: 90 - - # Пример второго сервера (раскомментируйте при необходимости): - # - name: backup-server2.example.com - # pools: - # - source_pool: tank - # datasets: - # - data/vms - # target_pool: backup - # snapshot_name: dd-mm-yyyy - # retention_days: 30 diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..a67d52d --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,81 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +""" +Загрузка конфигурации из YAML. +Файлы: config/migration.yaml, config/zfs_backup.yaml +""" +import os +from typing import Any, Dict, Optional + +import yaml + +CONFIG_DIR = os.path.dirname(os.path.abspath(__file__)) +DEFAULT_MIGRATION_CONFIG = os.path.join(CONFIG_DIR, "migration.yaml") +DEFAULT_ZFS_CONFIG = os.path.join(CONFIG_DIR, "zfs_backup.yaml") + + +def _load_yaml(path: str) -> Dict[str, Any]: + """Загрузить YAML-файл в словарь.""" + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def get_config(config_path: Optional[str] = None) -> Dict[str, Any]: + """ + Загрузка конфигурации миграции 1С (SSH, PostgreSQL, 1C, migration). + + Args: + config_path: Путь к migration.yaml. По умолчанию — config/migration.yaml. + + Returns: + dict: Секции ssh, postgresql, c1, migration, logging. + """ + path = config_path or os.environ.get("MIGRATION_CONFIG", DEFAULT_MIGRATION_CONFIG) + if not os.path.isabs(path): + # Относительно корня проекта (родитель config/) + root = os.path.dirname(CONFIG_DIR) + path = os.path.join(root, path) + data = _load_yaml(path) + + config = { + "ssh": data.get("ssh", {}), + "postgresql": data.get("postgresql", {}), + "c1": data.get("c1", {}), + "migration": dict(data.get("migration", {})), + "logging": data.get("logging", {"level": "INFO"}), + } + + mig = config["migration"] + archive = mig.get("archive_bases_name", []) + if mig.get("restore_bases_name") is None: + mig["restore_bases_name"] = list(archive) + if mig.get("bases") is None: + mig["bases"] = list(archive) + + def _norm_on_off(v: Any) -> str: + if v in (True, "true", "on", "1"): + return "on" + if v in (False, "false", "off", "0"): + return "off" + raise ValueError(f"Ожидается on/off, получено: {v!r}") + + scheduled = _norm_on_off(mig.get("scheduled_jobs_deny", "off")) + sessions = _norm_on_off(mig.get("sessions_deny", "off")) + mig["scheduled_jobs_deny"] = scheduled + mig["sessions_deny"] = sessions + + return config + + +def load_zfs_config(config_path: Optional[str] = None) -> Dict[str, Any]: + """ + Загрузка конфигурации ZFS Backup. + + Args: + config_path: Путь к zfs_backup.yaml. По умолчанию — config/zfs_backup.yaml. + """ + path = config_path or os.environ.get("ZFS_CONFIG", DEFAULT_ZFS_CONFIG) + if not os.path.isabs(path): + root = os.path.dirname(CONFIG_DIR) + path = os.path.join(root, path) + return _load_yaml(path) diff --git a/config/config_log.yaml b/config/config_log.yaml new file mode 100644 index 0000000..c3ddd84 --- /dev/null +++ b/config/config_log.yaml @@ -0,0 +1,32 @@ +# Конфигурация логирования (PRD v1.8) +# Используется: zfs_backup.py, modules/logger.py + +# Основные настройки +log_file: "/var/log/zfs_backup.log" +log_level: "INFO" + +# Telegram интеграция +telegram: + enabled: true + bot_token: "8294703499:AAGfSNSxe9NOrh9wD7FeQcWax9yq1bGBqBY" + chat_id: "-5101685781" + + allowed_users: + - 86018113 + + log_level: "INFO" + allowed_levels: + - "ERROR" + - "INFO" + - "WARNING" + + retry: + attempts: 3 + delay: 5 + + options: + parse_mode: "HTML" + disable_notification: false + max_message_length: 4096 + + enable_commands: true diff --git a/config/migration.yaml b/config/migration.yaml new file mode 100644 index 0000000..201708c --- /dev/null +++ b/config/migration.yaml @@ -0,0 +1,41 @@ +# Конфигурация миграции 1С, PostgreSQL, SSH +# Используется: 1c-migration.py, example_postgresql.py, example_c1_cluster.py + +logging: + level: INFO + +ssh: + hostname: g.it.cln.su + port: 22222 + username: root + pkey_file: /root/.ssh/id_rsa + host_keys: "~/.ssh/known_hosts" + +postgresql: + archive_server: 1c.it.cln.su + restore_server: postgres.it.cln.su + backup_date: "16.12.2025" + extra_backup: true + postgres_user: postgres + postgres_password: PrestigePostgres + +c1: + lxc_container_name: c1 + cluster_user: neon + cluster_password: "Pre$tige310582" + db_server: postgres.it.cln.su + db_user: postgres + db_password: PrestigePostgres + infobase_user: neon + infobase_password: "$F%G^H&J*K" + +migration: + archive_bases_name: + - salon + - lmotor-ut + - staretail + - uran-ut + restore_bases_name: null # если null — используется archive_bases_name + bases: null # для примеров; если null — archive_bases_name + scheduled_jobs_deny: on + sessions_deny: off diff --git a/config/zfs_backup.yaml b/config/zfs_backup.yaml new file mode 100644 index 0000000..6beefeb --- /dev/null +++ b/config/zfs_backup.yaml @@ -0,0 +1,23 @@ +# Конфигурация ZFS Backup v1.1 +# Запуск: python zfs_backup.py --config=config/zfs_backup.yaml + +ssh_defaults: + port: 22222 + username: root + pkey_file: /root/.ssh/id_rsa + host_keys: "~/.ssh/known_hosts" + +servers: + - name: gwo2.mps.cln.su + pools: + - source_pool: zp2 + datasets: + - containers/smb2 + target_pool: fast-backup + - source_pool: zp0 + datasets: + - containers/www + - containers/voip + target_pool: fast-backup + snapshot_name: dd-mm-yyyy + retention_days: 90 diff --git a/example_c1_cluster.py b/example_c1_cluster.py index 2561f0e..63d3624 100755 --- a/example_c1_cluster.py +++ b/example_c1_cluster.py @@ -1,12 +1,11 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- """ -Примеры использования модуля c1_cluster.py новым способом -Демонстрирует прямое использование C1ClusterOperations без основного класса ssh +Примеры использования модуля c1_cluster через единый SSHClient. +Паттерн: SSHClient + connect() + set_c1_config() + операции + close() """ import config -from modules.ssh_base import SSHBase -from modules.c1_cluster import C1ClusterOperations +from modules import SSHClient def example_basic_usage(): @@ -17,37 +16,27 @@ def example_basic_usage(): print("Пример 1: Базовое использование модуля c1_cluster") print("=" * 60) - # Загружаем конфигурацию cfg = config.get_config() - - # Создаем SSH подключение - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], pkey_file=cfg['ssh']['pkey_file'], - host_keys=cfg['ssh']['host_keys'] + host_keys=cfg['ssh']['host_keys'], ) - - # Подключаемся - ssh_client.connect() - - # Создаем экземпляр модуля 1С кластера с параметрами из конфига - lxc_container = cfg['c1']['lxc_container_name'] - cluster_user = cfg['c1']['cluster_user'] - cluster_password = cfg['c1']['cluster_password'] - c1_cluster = C1ClusterOperations(ssh_client, lxc_container, cluster_user, cluster_password) - - # Получаем версию кластера - version = c1_cluster.cluster_version() + client.connect() + client.set_c1_config( + cfg['c1']['lxc_container_name'], + cfg['c1']['cluster_user'], + cfg['c1']['cluster_password'], + ) + + version = client.cluster_version() print(f"Версия кластера 1С: {version}") - - # Получаем ID кластера - cluster_id = c1_cluster.cluster_id() + cluster_id = client.cluster_id() print(f"ID кластера 1С: {cluster_id}") - - # Закрываем соединение - ssh_client.close() + + client.close() print("\n") @@ -61,22 +50,21 @@ def example_get_base_list(): cfg = config.get_config() - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], - pkey_file=cfg['ssh']['pkey_file'] + pkey_file=cfg['ssh']['pkey_file'], + host_keys=cfg['ssh'].get('host_keys', '~/.ssh/known_hosts'), ) - ssh_client.connect() - - # Создаем экземпляр модуля 1С кластера с параметрами из конфига - lxc_container = cfg['c1']['lxc_container_name'] - cluster_user = cfg['c1']['cluster_user'] - cluster_password = cfg['c1']['cluster_password'] - c1_cluster = C1ClusterOperations(ssh_client, lxc_container, cluster_user, cluster_password) - - # Получаем список баз данных - bases = c1_cluster.base_list() + client.connect() + client.set_c1_config( + cfg['c1']['lxc_container_name'], + cfg['c1']['cluster_user'], + cfg['c1']['cluster_password'], + ) + + bases = client.base_list() print(f"Найдено баз данных: {len(bases)}") for base in bases: @@ -85,7 +73,7 @@ def example_get_base_list(): base_id = base['id'][0] if base.get('id') and len(base['id']) > 0 else 'N/A' print(f" - {base_name} (ID: {base_id})") - ssh_client.close() + client.close() print("\n") @@ -99,28 +87,27 @@ def example_get_base_info(): cfg = config.get_config() - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], - pkey_file=cfg['ssh']['pkey_file'] + pkey_file=cfg['ssh']['pkey_file'], + host_keys=cfg['ssh'].get('host_keys', '~/.ssh/known_hosts'), + ) + client.connect() + client.set_c1_config( + cfg['c1']['lxc_container_name'], + cfg['c1']['cluster_user'], + cfg['c1']['cluster_password'], ) - ssh_client.connect() - - # Создаем экземпляр модуля 1С кластера с параметрами из конфига - lxc_container = cfg['c1']['lxc_container_name'] - cluster_user = cfg['c1']['cluster_user'] - cluster_password = cfg['c1']['cluster_password'] infobase_user = cfg['c1']['infobase_user'] infobase_password = cfg['c1']['infobase_password'] - c1_cluster = C1ClusterOperations(ssh_client, lxc_container, cluster_user, cluster_password) - - # Получаем список баз из конфигурации + bases_to_process = cfg['migration'].get('bases', []) if not bases_to_process: print("Список баз для обработки не указан в конфигурации (migration.bases)") - ssh_client.close() + client.close() return print(f"Обработка {len(bases_to_process)} баз данных из конфигурации:") @@ -134,12 +121,12 @@ def example_get_base_info(): try: # Получаем ID базы - base_id = c1_cluster.base_id(base_name) + base_id = client.base_id(base_name) if base_id: print(f"ID базы данных '{base_name}': {base_id}") # Получаем полную информацию о базе - base_info = c1_cluster.base_info(base_name, infobase_user, infobase_password) + base_info = client.base_info(base_name, infobase_user, infobase_password) if base_info: print(f"\nИнформация о базе данных '{base_name}':") @@ -154,7 +141,7 @@ def example_get_base_info(): print() - ssh_client.close() + client.close() print("\n") @@ -168,13 +155,14 @@ def example_update_base_info(): cfg = config.get_config() - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], - pkey_file=cfg['ssh']['pkey_file'] + pkey_file=cfg['ssh']['pkey_file'], + host_keys=cfg['ssh'].get('host_keys', '~/.ssh/known_hosts'), ) - ssh_client.connect() + client.connect() # Создаем экземпляр модуля 1С кластера с параметрами из конфига lxc_container = cfg['c1']['lxc_container_name'] @@ -182,8 +170,8 @@ def example_update_base_info(): cluster_password = cfg['c1']['cluster_password'] infobase_user = cfg['c1']['infobase_user'] infobase_password = cfg['c1']['infobase_password'] - c1_cluster = C1ClusterOperations(ssh_client, lxc_container, cluster_user, cluster_password) - + client.set_c1_config(lxc_container, cluster_user, cluster_password) + # Параметры базы данных PostgreSQL db_server = cfg['c1']['db_server'] db_name = cfg['c1'].get('db_name', '') @@ -199,7 +187,7 @@ def example_update_base_info(): if not bases_to_process: print("Список баз для обработки не указан в конфигурации (migration.bases)") - ssh_client.close() + client.close() return print(f"Обработка {len(bases_to_process)} баз данных из конфигурации:") @@ -231,7 +219,7 @@ def example_update_base_info(): print(f" Запрет сеансов: {sessions_deny}") # Обновляем информацию о базе - updated_base_id = c1_cluster.base_info_update( + updated_base_id = client.base_info_update( base_name, db_server, actual_db_name, db_user, db_password, infobase_user, infobase_password, scheduled_jobs_deny, sessions_deny @@ -246,7 +234,7 @@ def example_update_base_info(): print() - ssh_client.close() + client.close() print("\n") @@ -260,29 +248,30 @@ def example_workflow(): cfg = config.get_config() - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], - pkey_file=cfg['ssh']['pkey_file'] + pkey_file=cfg['ssh']['pkey_file'], + host_keys=cfg['ssh'].get('host_keys', '~/.ssh/known_hosts'), ) - ssh_client.connect() + client.connect() # Создаем экземпляр модуля 1С кластера с параметрами из конфига lxc_container = cfg['c1']['lxc_container_name'] cluster_user = cfg['c1']['cluster_user'] cluster_password = cfg['c1']['cluster_password'] - c1_cluster = C1ClusterOperations(ssh_client, lxc_container, cluster_user, cluster_password) - + client.set_c1_config(lxc_container, cluster_user, cluster_password) + try: # Шаг 1: Получаем версию кластера print("Шаг 1: Получение версии кластера...") - version = c1_cluster.cluster_version() + version = client.cluster_version() print(f" Версия: {version}") # Шаг 2: Запускаем демон кластера print("\nШаг 2: Запуск демона кластера...") - err = c1_cluster.cluster_daemon_start() + err = client.cluster_daemon_start() if err: print(f" Предупреждение: {err}") else: @@ -290,12 +279,12 @@ def example_workflow(): # Шаг 3: Получаем ID кластера print("\nШаг 3: Получение ID кластера...") - cluster_id = c1_cluster.cluster_id() + cluster_id = client.cluster_id() print(f" ID кластера: {cluster_id}") # Шаг 4: Получаем список баз данных print("\nШаг 4: Получение списка баз данных...") - bases = c1_cluster.base_list() + bases = client.base_list() print(f" Найдено баз: {len(bases)}") # Шаг 5: Для каждой базы получаем ID @@ -303,13 +292,13 @@ def example_workflow(): for base in bases: if base.get('name') and len(base['name']) > 0: base_name = base['name'][0] - base_id = c1_cluster.base_id(base_name) + base_id = client.base_id(base_name) print(f" {base_name}: {base_id}") except Exception as e: print(f"Ошибка: {e}") finally: - ssh_client.close() + client.close() print("\n") @@ -323,28 +312,28 @@ def example_context_manager_style(): print("=" * 60) cfg = config.get_config() - ssh_client = None + client = None try: - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], pkey_file=cfg['ssh']['pkey_file'] ) - ssh_client.connect() + client.connect() # Создаем экземпляр модуля 1С кластера с параметрами из конфига lxc_container = cfg['c1']['lxc_container_name'] cluster_user = cfg['c1']['cluster_user'] cluster_password = cfg['c1']['cluster_password'] - c1_cluster = C1ClusterOperations(ssh_client, lxc_container, cluster_user, cluster_password) - + client.set_c1_config(lxc_container, cluster_user, cluster_password) + # Получаем версию и список баз - version = c1_cluster.cluster_version() + version = client.cluster_version() print(f"Версия кластера: {version}") - bases = c1_cluster.base_list() + bases = client.base_list() print(f"Список баз данных ({len(bases)} шт.):") for base in bases[:3]: # Показываем только первые 3 if base.get('name') and len(base['name']) > 0: @@ -353,8 +342,8 @@ def example_context_manager_style(): except Exception as e: print(f"Произошла ошибка: {e}") finally: - if ssh_client: - ssh_client.close() + if client: + client.close() print("Соединение закрыто") print("\n") @@ -362,7 +351,7 @@ def example_context_manager_style(): if __name__ == "__main__": print("\n" + "=" * 60) - print("Примеры использования модуля c1_cluster.py") + print("Примеры использования модуля client.py") print("=" * 60 + "\n") # Раскомментируйте нужные примеры для запуска diff --git a/example_postgresql.py b/example_postgresql.py index 54ca35b..11ee0c5 100755 --- a/example_postgresql.py +++ b/example_postgresql.py @@ -1,12 +1,11 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- """ -Примеры использования модуля postgresql.py новым способом -Демонстрирует прямое использование PostgreSQLOperations без основного класса ssh +Примеры использования модуля postgresql через единый SSHClient. +Паттерн: SSHClient + connect() + операции + close() """ import config -from modules.ssh_base import SSHBase -from modules.postgresql import PostgreSQLOperations +from modules import SSHClient def example_basic_usage(): @@ -17,35 +16,25 @@ def example_basic_usage(): print("Пример 1: Базовое использование модуля postgresql") print("=" * 60) - # Загружаем конфигурацию cfg = config.get_config() - - # Создаем SSH подключение - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], pkey_file=cfg['ssh']['pkey_file'], - host_keys=cfg['ssh']['host_keys'] + host_keys=cfg['ssh']['host_keys'], ) - - # Подключаемся - ssh_client.connect() - - # Создаем экземпляр модуля PostgreSQL - pg = PostgreSQLOperations(ssh_client) - - # Получаем список баз данных + client.connect() + srv_pgsql = cfg['postgresql']['archive_server'] - bases = pg.bases_list(srv_pgsql) + bases = client.bases_list(srv_pgsql) print(f"Сервер PostgreSQL: {srv_pgsql}") print(f"Найдено баз данных: {len(bases)}") for base in bases[:5]: # Показываем первые 5 print(f" - {base}") - # Закрываем соединение - ssh_client.close() + client.close() print("\n") @@ -58,28 +47,23 @@ def example_get_bases_list(): print("=" * 60) cfg = config.get_config() - - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], - pkey_file=cfg['ssh']['pkey_file'] + pkey_file=cfg['ssh']['pkey_file'], + host_keys=cfg['ssh'].get('host_keys', '~/.ssh/known_hosts'), ) - ssh_client.connect() - - pg = PostgreSQLOperations(ssh_client) - + client.connect() + srv_pgsql = cfg['postgresql']['archive_server'] - - # Получаем список баз данных - bases = pg.bases_list(srv_pgsql) - + bases = client.bases_list(srv_pgsql) print(f"Сервер: {srv_pgsql}") print(f"Всего баз данных: {len(bases)}") print("\nСписок баз данных:") - pg.bases_list_print(srv_pgsql) - - ssh_client.close() + client.bases_list_print(srv_pgsql) + + client.close() print("\n") @@ -92,27 +76,24 @@ def example_get_bases_size(): print("=" * 60) cfg = config.get_config() - - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], - pkey_file=cfg['ssh']['pkey_file'] + pkey_file=cfg['ssh']['pkey_file'], + host_keys=cfg['ssh'].get('host_keys', '~/.ssh/known_hosts'), ) - ssh_client.connect() - - pg = PostgreSQLOperations(ssh_client) - + client.connect() + srv_pgsql = cfg['postgresql']['archive_server'] - print(f"Сервер: {srv_pgsql}") print("\nРазмеры баз данных:") print("-" * 60) print(f"{'База данных':<30} | Размер") print("-" * 60) - pg.bases_size_print(srv_pgsql) - - ssh_client.close() + client.bases_size_print(srv_pgsql) + + client.close() print("\n") @@ -125,28 +106,24 @@ def example_backup_single_base(): print("=" * 60) cfg = config.get_config() - - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], - pkey_file=cfg['ssh']['pkey_file'] + pkey_file=cfg['ssh']['pkey_file'], + host_keys=cfg['ssh'].get('host_keys', '~/.ssh/known_hosts'), ) - ssh_client.connect() - - pg = PostgreSQLOperations(ssh_client) - + client.connect() + srv_pgsql = cfg['postgresql']['archive_server'] - - # Получаем список баз и берем первую для примера - bases = pg.bases_list(srv_pgsql) + bases = client.bases_list(srv_pgsql) if bases: base_name = bases[0] print(f"Создание бэкапа базы данных: {base_name}") print(f"Сервер: {srv_pgsql}") try: - results = pg.bases_backup(srv_pgsql, base_name) + results = client.bases_backup(srv_pgsql, base_name) for result in results: if result['success']: print(f"✓ Бэкап базы '{result['base']}' создан успешно") @@ -156,8 +133,8 @@ def example_backup_single_base(): print(f"Ошибка: {e}") else: print("Базы данных не найдены") - - ssh_client.close() + + client.close() print("\n") @@ -171,18 +148,16 @@ def example_backup_multiple_bases(): cfg = config.get_config() - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], - pkey_file=cfg['ssh']['pkey_file'] + pkey_file=cfg['ssh']['pkey_file'], + host_keys=cfg['ssh'].get('host_keys', '~/.ssh/known_hosts'), ) - ssh_client.connect() - - pg = PostgreSQLOperations(ssh_client) - + client.connect() + srv_pgsql = cfg['postgresql']['archive_server'] - # Получаем список баз из конфигурации для примеров bases_to_backup = cfg['migration'].get('bases', []) @@ -202,7 +177,7 @@ def example_backup_multiple_bases(): if bases_to_backup: for base_name in bases_to_backup: print(f"\nСоздание бэкапа базы: {base_name}") - results = pg.bases_backup(srv_pgsql, base_name) + results = client.bases_backup(srv_pgsql, base_name) for result in results: if result['success']: print(f" ✓ Бэкап базы '{result['base']}' создан успешно") @@ -210,7 +185,7 @@ def example_backup_multiple_bases(): print(f" ✗ Ошибка: {result['stderr']}") else: # Бэкап всех баз - results = pg.bases_backup(srv_pgsql, None) + results = client.bases_backup(srv_pgsql, None) print(f"\nОбработано баз: {len(results)}") successful = sum(1 for r in results if r['success']) failed = len(results) - successful @@ -222,7 +197,7 @@ def example_backup_multiple_bases(): except Exception as e: print(f"Ошибка: {e}") - ssh_client.close() + client.close() print("\n") @@ -236,28 +211,26 @@ def example_backup_all_bases(): cfg = config.get_config() - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], - pkey_file=cfg['ssh']['pkey_file'] + pkey_file=cfg['ssh']['pkey_file'], + host_keys=cfg['ssh'].get('host_keys', '~/.ssh/known_hosts'), ) - ssh_client.connect() - - pg = PostgreSQLOperations(ssh_client) - + client.connect() + srv_pgsql = cfg['postgresql']['archive_server'] - print(f"Сервер: {srv_pgsql}") print("Создание бэкапа всех баз данных...") try: - result = pg.bases_backup_all(srv_pgsql) + result = client.bases_backup_all(srv_pgsql) print("✓ Бэкап всех баз данных завершен успешно") except Exception as e: print(f"✗ Ошибка при создании бэкапа: {e}") - ssh_client.close() + client.close() print("\n") @@ -271,16 +244,15 @@ def example_create_and_drop_base(): cfg = config.get_config() - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], - pkey_file=cfg['ssh']['pkey_file'] + pkey_file=cfg['ssh']['pkey_file'], + host_keys=cfg['ssh'].get('host_keys', '~/.ssh/known_hosts'), ) - ssh_client.connect() - - pg = PostgreSQLOperations(ssh_client) - + client.connect() + srv_pgsql = cfg['postgresql']['restore_server'] test_base_name = "test_base_example" @@ -290,28 +262,28 @@ def example_create_and_drop_base(): try: # Создаем базу данных print("\n1. Создание базы данных...") - pg.base_create(srv_pgsql, test_base_name) + client.base_create(srv_pgsql, test_base_name) print(f"✓ База данных '{test_base_name}' создана") # Проверяем, что база создана - bases = pg.bases_list(srv_pgsql) + bases = client.bases_list(srv_pgsql) if test_base_name in bases: print(f"✓ База '{test_base_name}' найдена в списке баз") # Удаляем базу данных print("\n2. Удаление базы данных...") - pg.base_drop(srv_pgsql, test_base_name) + client.base_drop(srv_pgsql, test_base_name) print(f"✓ База данных '{test_base_name}' удалена") # Проверяем, что база удалена - bases = pg.bases_list(srv_pgsql) + bases = client.bases_list(srv_pgsql) if test_base_name not in bases: print(f"✓ База '{test_base_name}' отсутствует в списке баз") except Exception as e: print(f"✗ Ошибка: {e}") - ssh_client.close() + client.close() print("\n") @@ -325,16 +297,15 @@ def example_restore_base(): cfg = config.get_config() - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], - pkey_file=cfg['ssh']['pkey_file'] + pkey_file=cfg['ssh']['pkey_file'], + host_keys=cfg['ssh'].get('host_keys', '~/.ssh/known_hosts'), ) - ssh_client.connect() - - pg = PostgreSQLOperations(ssh_client) - + client.connect() + archive_server = cfg['postgresql']['archive_server'] restore_server = cfg['postgresql']['restore_server'] backup_date = cfg['postgresql']['backup_date'] @@ -358,19 +329,19 @@ def example_restore_base(): # Сначала удаляем старую базу, если существует print(f"\n1. Удаление старой базы '{restore_base}' (если существует)...") try: - pg.base_drop(restore_server, restore_base) + client.base_drop(restore_server, restore_base) print(f" ✓ Старая база удалена") except Exception: print(f" База не существует или уже удалена") # Создаем новую базу print(f"\n2. Создание новой базы '{restore_base}'...") - pg.base_create(restore_server, restore_base) + client.base_create(restore_server, restore_base) print(f" ✓ База создана") # Восстанавливаем из бэкапа print(f"\n3. Восстановление из бэкапа...") - pg.base_restore( + client.base_restore( archive_server, restore_server, backup_date, archive_base, restore_base, extra ) @@ -379,7 +350,7 @@ def example_restore_base(): except Exception as e: print(f" ✗ Ошибка при восстановлении: {e}") - ssh_client.close() + client.close() print("\n") @@ -393,16 +364,15 @@ def example_manage_backups(): cfg = config.get_config() - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], - pkey_file=cfg['ssh']['pkey_file'] + pkey_file=cfg['ssh']['pkey_file'], + host_keys=cfg['ssh'].get('host_keys', '~/.ssh/known_hosts'), ) - ssh_client.connect() - - pg = PostgreSQLOperations(ssh_client) - + client.connect() + srv_pgsql = cfg['postgresql']['archive_server'] backup_path = f'/backup/pgsql/{srv_pgsql}' days_old = 30 # Удалять бэкапы старше 30 дней @@ -413,7 +383,7 @@ def example_manage_backups(): try: # Получаем список старых директорий print("\n1. Поиск старых бэкапов...") - old_dirs = pg.file_list(backup_path, days_old) + old_dirs = client.file_list(backup_path, days_old) if old_dirs.strip(): dirs_list = [d.strip() for d in old_dirs.split('\n') if d.strip()] print(f"Найдено директорий для удаления: {len(dirs_list)}") @@ -424,13 +394,13 @@ def example_manage_backups(): # Удаляем старые бэкапы print(f"\n2. Удаление бэкапов старше {days_old} дней...") - pg.delete_old_backups(backup_path, days_old) + client.delete_old_backups(backup_path, days_old) print("✓ Удаление завершено") except Exception as e: print(f"✗ Ошибка: {e}") - ssh_client.close() + client.close() print("\n") @@ -444,27 +414,25 @@ def example_full_workflow(): cfg = config.get_config() - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], - pkey_file=cfg['ssh']['pkey_file'] + pkey_file=cfg['ssh']['pkey_file'], + host_keys=cfg['ssh'].get('host_keys', '~/.ssh/known_hosts'), ) - ssh_client.connect() - - pg = PostgreSQLOperations(ssh_client) - + client.connect() + srv_pgsql = cfg['postgresql']['archive_server'] - try: # Шаг 1: Получаем список баз print("Шаг 1: Получение списка баз данных...") - bases = pg.bases_list(srv_pgsql) + bases = client.bases_list(srv_pgsql) print(f" Найдено баз: {len(bases)}") # Шаг 2: Получаем размеры баз print("\nШаг 2: Получение размеров баз данных...") - sizes = pg.bases_size(srv_pgsql) + sizes = client.bases_size(srv_pgsql) print(f" Получены размеры для {len(sizes)} баз") for size_info in sizes[:3]: # Показываем первые 3 base_name = size_info[0] @@ -474,7 +442,7 @@ def example_full_workflow(): # Шаг 3: Создаем бэкап первой базы (если есть) if bases: print(f"\nШаг 3: Создание бэкапа базы '{bases[0]}'...") - results = pg.bases_backup(srv_pgsql, bases[0]) + results = client.bases_backup(srv_pgsql, bases[0]) for result in results: if result['success']: print(f" ✓ Бэкап создан успешно") @@ -484,7 +452,7 @@ def example_full_workflow(): except Exception as e: print(f"Ошибка: {e}") finally: - ssh_client.close() + client.close() print("\n") @@ -498,23 +466,21 @@ def example_context_manager_style(): print("=" * 60) cfg = config.get_config() - ssh_client = None + client = None try: - ssh_client = SSHBase( + client = SSHClient( hostname=cfg['ssh']['hostname'], port=cfg['ssh']['port'], username=cfg['ssh']['username'], pkey_file=cfg['ssh']['pkey_file'] ) - ssh_client.connect() - - pg = PostgreSQLOperations(ssh_client) - + client.connect() + srv_pgsql = cfg['postgresql']['archive_server'] # Получаем список баз и их размеры - bases = pg.bases_list(srv_pgsql) + bases = client.bases_list(srv_pgsql) print(f"Сервер: {srv_pgsql}") print(f"Найдено баз данных: {len(bases)}") @@ -526,8 +492,8 @@ def example_context_manager_style(): except Exception as e: print(f"Произошла ошибка: {e}") finally: - if ssh_client: - ssh_client.close() + if client: + client.close() print("\nСоединение закрыто") print("\n") diff --git a/modules/__init__.py b/modules/__init__.py index f104011..2ef5ede 100644 --- a/modules/__init__.py +++ b/modules/__init__.py @@ -7,7 +7,16 @@ from .ssh_base import SSHBase from .postgresql import PostgreSQLOperations from .c1_cluster import C1ClusterOperations -from .ssh import ssh +from .ssh import SSHClient, ssh +from .protocols import SSHProtocol, SSHOperationsBase -__all__ = ['SSHBase', 'PostgreSQLOperations', 'C1ClusterOperations', 'ssh'] +__all__ = [ + 'SSHBase', + 'SSHClient', + 'ssh', + 'PostgreSQLOperations', + 'C1ClusterOperations', + 'SSHProtocol', + 'SSHOperationsBase', +] diff --git a/modules/c1_cluster.py b/modules/c1_cluster.py index b4e9ddb..33d8650 100644 --- a/modules/c1_cluster.py +++ b/modules/c1_cluster.py @@ -6,27 +6,30 @@ import logging from typing import Optional, List, Dict -logger = logging.getLogger(__name__) +from .logger import get_logger +from .protocols import SSHOperationsBase + +logger = get_logger("c1_cluster") -class C1ClusterOperations: +class C1ClusterOperations(SSHOperationsBase): """ - Класс для операций с кластером 1С через SSH + Класс для операций с кластером 1С через SSH. + Реализует контракт операционного модуля (ssh: SSHProtocol). """ + def __init__(self, ssh_client, srv_1c: str = "", c1_claster_user: str = "", c1_claster_pass: str = ""): """ - Инициализация модуля 1С кластера - Args: - ssh_client: Экземпляр SSHBase для выполнения команд + ssh_client: Экземпляр, реализующий SSHProtocol (SSHBase, SSHClient). srv_1c: Имя LXC контейнера с 1С c1_claster_user: Пользователь кластера 1С c1_claster_pass: Пароль кластера 1С """ - self.ssh = ssh_client - self.srv_1c = srv_1c - self.c1_claster_user = c1_claster_user - self.c1_claster_pass = c1_claster_pass + super().__init__(ssh_client) + self.srv_1c: str = srv_1c + self.c1_claster_user: str = c1_claster_user + self.c1_claster_pass: str = c1_claster_pass def set_srv_1c(self, srv_1c: str) -> None: """ diff --git a/modules/logger.py b/modules/logger.py new file mode 100644 index 0000000..6bbf2b5 --- /dev/null +++ b/modules/logger.py @@ -0,0 +1,283 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +""" +Единый логгер для проекта SSH Client (PRD v1.8). +Поддержка файла, консоли и Telegram через TelegramHandler. +""" +import logging +import os +import queue +import sys +import threading +import time +from typing import Any, Dict, List, Optional, Union + +# Имя корневого логгера проекта +ROOT_LOGGER_NAME = "ssh_client" + +# Общий формат логов +LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + +# Кастомный уровень STATUS (между INFO и WARNING) +STATUS_LEVEL = 25 +logging.addLevelName(STATUS_LEVEL, "STATUS") + + +def status(self, message: str, *args, **kwargs) -> None: + if self.isEnabledFor(STATUS_LEVEL): + self._log(STATUS_LEVEL, message, args, **kwargs) + + +logging.Logger.status = status # type: ignore + +_initialized = False + + +class TelegramHandler(logging.Handler): + """ + Отправляет логи в Telegram через очередь (асинхронно). + Retry при ошибках, разбивка длинных сообщений, /start для верификации. + """ + + def __init__(self, config: Dict[str, Any]) -> None: + super().__init__() + self._config = config + self._queue: queue.Queue = queue.Queue(maxsize=1000) + self._bot_token = config.get("bot_token", "") + self._chat_id = str(config.get("chat_id", "")) + self._allowed_users: List[Any] = config.get("allowed_users", []) + self._allowed_levels: List[str] = [str(x).upper() for x in config.get("allowed_levels", ["ERROR"])] + retry = config.get("retry", {}) + self._retry_attempts = retry.get("attempts", 3) + self._retry_delay = retry.get("delay", 5) + opts = config.get("options", {}) + self._max_length = opts.get("max_message_length", 4096) + self._parse_mode = opts.get("parse_mode", "HTML") + self._disable_notification = opts.get("disable_notification", False) + self._enable_commands = config.get("enable_commands", False) + self._stop = threading.Event() + self._worker = threading.Thread(target=self._process_queue, daemon=True) + self._worker.start() + if self._enable_commands: + self._poll_thread = threading.Thread(target=self._poll_commands, daemon=True) + self._poll_thread.start() + + def _level_allowed(self, record: logging.LogRecord) -> bool: + levelname = getattr(record, "levelname", record.levelname) + return str(levelname).upper() in self._allowed_levels + + def emit(self, record: logging.LogRecord) -> None: + if not self._bot_token or not self._chat_id: + return + if not self._level_allowed(record): + return + try: + msg = self.format(record) + # Экранируем HTML при parse_mode=HTML + if self._parse_mode == "HTML": + msg = msg.replace("&", "&").replace("<", "<").replace(">", ">") + self._queue.put_nowait(msg) + except queue.Full: + sys.stderr.write(f"[TelegramHandler] Очередь переполнена, сообщение пропущено\n") + except Exception: + self.handleError(record) + + def _process_queue(self) -> None: + while not self._stop.is_set(): + try: + msg = self._queue.get(timeout=0.5) + if msg: + self._send_with_retry(msg) + except queue.Empty: + continue + except Exception as e: + sys.stderr.write(f"[TelegramHandler] Ошибка отправки: {e}\n") + + def _split_message(self, text: str) -> List[str]: + if len(text) <= self._max_length: + return [text] + parts = [] + while text: + chunk = text[: self._max_length] + idx = chunk.rfind("\n") + if idx > self._max_length // 2: + chunk, text = chunk[: idx + 1], text[idx + 1 :] + else: + text = text[self._max_length :] + parts.append(chunk) + return parts + + def _send_with_retry(self, text: str) -> None: + parts = self._split_message(text) + for part in parts: + for attempt in range(1, self._retry_attempts + 1): + try: + self._send_to_telegram(part) + break + except Exception as e: + if attempt == self._retry_attempts: + sys.stderr.write(f"[TelegramHandler] Не удалось отправить после {attempt} попыток: {e}\n") + return + time.sleep(self._retry_delay) + + def _send_to_telegram(self, text: str) -> None: + import requests + + url = f"https://api.telegram.org/bot{self._bot_token}/sendMessage" + data = { + "chat_id": self._chat_id, + "text": text, + "parse_mode": self._parse_mode, + "disable_notification": self._disable_notification, + } + resp = requests.post(url, data=data, timeout=10) + resp.raise_for_status() + j = resp.json() + if not j.get("ok"): + raise RuntimeError(j.get("description", "Unknown Telegram API error")) + + def _poll_commands(self) -> None: + import requests + + url = f"https://api.telegram.org/bot{self._bot_token}/getUpdates" + offset = 0 + while not self._stop.is_set(): + try: + r = requests.get(url, params={"offset": offset, "timeout": 30}, timeout=35) + r.raise_for_status() + data = r.json() + if not data.get("ok"): + time.sleep(5) + continue + for upd in data.get("result", []): + offset = upd["update_id"] + 1 + msg = upd.get("message", {}) + text = msg.get("text", "").strip() + if text != "/start": + continue + user = msg.get("from", {}) + user_id = user.get("id") + username = user.get("username", "") + chat_id = msg.get("chat", {}).get("id") + if self._authenticate_user(user_id, username): + reply = "✅ Доступ разрешён. Вы будете получать уведомления." + else: + reply = "❌ Доступ запрещён." + self._send_reply(chat_id, reply) + except Exception as e: + sys.stderr.write(f"[TelegramHandler] Polling error: {e}\n") + time.sleep(5) + + def _send_reply(self, chat_id: Any, text: str) -> None: + import requests + + url = f"https://api.telegram.org/bot{self._bot_token}/sendMessage" + requests.post(url, data={"chat_id": chat_id, "text": text}, timeout=10) + + def _authenticate_user(self, user_id: Optional[int], username: str) -> bool: + for u in self._allowed_users: + if isinstance(u, int) and u == user_id: + return True + if isinstance(u, str) and (u == username or str(u) == str(user_id)): + return True + return False + + def close(self) -> None: + self._stop.set() + super().close() + + +def _parse_level(level: str) -> int: + m = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "STATUS": STATUS_LEVEL, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL, + } + return m.get(str(level).upper(), logging.INFO) + + +def setup_root_logger( + level: Union[int, str] = logging.INFO, + log_file: Optional[str] = None, + script_dir: Optional[str] = None, + telegram_config: Optional[Dict[str, Any]] = None, +) -> logging.Logger: + """ + Настроить корневой логгер проекта. + + Args: + level: Уровень логирования (int или "INFO", "DEBUG" и т.д.) + log_file: Путь к файлу логов (опционально) + script_dir: Директория скрипта для fallback (опционально) + telegram_config: Настройки Telegram (если None — Telegram отключён) + + Returns: + Настроенный логгер + """ + global _initialized + logger = logging.getLogger(ROOT_LOGGER_NAME) + formatter = logging.Formatter(LOG_FORMAT, datefmt=LOG_DATE_FORMAT) + + if isinstance(level, str): + level = _parse_level(level) + + if not _initialized: + logger.setLevel(level) + ch = logging.StreamHandler() + ch.setFormatter(formatter) + logger.addHandler(ch) + _initialized = True + + if log_file: + has_file = any( + isinstance(h, logging.FileHandler) + and getattr(h, "baseFilename", "").endswith(os.path.basename(log_file)) + for h in logger.handlers + ) + if not has_file: + try: + log_path = log_file + if script_dir and not os.path.isabs(log_path): + log_path = os.path.normpath(os.path.join(script_dir, log_file)) + fh = logging.FileHandler(log_path, encoding="utf-8") + fh.setFormatter(formatter) + logger.addHandler(fh) + except OSError: + if script_dir: + fallback = os.path.join(script_dir, "logs", "zfs_backup.log") + try: + os.makedirs(os.path.dirname(fallback), exist_ok=True) + fh = logging.FileHandler(fallback, encoding="utf-8") + fh.setFormatter(formatter) + logger.addHandler(fh) + logger.warning("Не удалось писать в %s, используется %s", log_file, fallback) + except OSError: + pass + + if telegram_config and telegram_config.get("enabled"): + if not any(isinstance(h, TelegramHandler) for h in logger.handlers): + try: + th = TelegramHandler(telegram_config) + th.setLevel(_parse_level(telegram_config.get("log_level", "INFO"))) + th.setFormatter(formatter) + logger.addHandler(th) + except Exception as e: + sys.stderr.write(f"[logger] Не удалось инициализировать TelegramHandler: {e}\n") + + return logger + + +def get_logger(name: str) -> logging.Logger: + """ + Получить логгер для модуля. + """ + root = logging.getLogger(ROOT_LOGGER_NAME) + if not root.handlers: + setup_root_logger() + if name.startswith("ssh_client"): + return logging.getLogger(name) + return logging.getLogger(f"{ROOT_LOGGER_NAME}.{name}") diff --git a/modules/postgresql.py b/modules/postgresql.py index 63c603a..eadfe11 100644 --- a/modules/postgresql.py +++ b/modules/postgresql.py @@ -7,21 +7,24 @@ import logging from datetime import datetime from typing import Optional, List, Dict, Union -logger = logging.getLogger(__name__) +from .logger import get_logger +from .protocols import SSHOperationsBase + +logger = get_logger("postgresql") -class PostgreSQLOperations: +class PostgreSQLOperations(SSHOperationsBase): """ - Класс для операций с PostgreSQL через SSH + Класс для операций с PostgreSQL через SSH. + Реализует контракт операционного модуля (ssh: SSHProtocol). """ - def __init__(self, ssh_client): + + def __init__(self, ssh_client) -> None: """ - Инициализация модуля PostgreSQL - Args: - ssh_client: Экземпляр SSHBase для выполнения команд + ssh_client: Экземпляр, реализующий SSHProtocol (SSHBase, SSHClient). """ - self.ssh = ssh_client + super().__init__(ssh_client) def bases_list(self, srv_pgsql: str) -> List[str]: """ diff --git a/modules/protocols.py b/modules/protocols.py new file mode 100644 index 0000000..56b242b --- /dev/null +++ b/modules/protocols.py @@ -0,0 +1,52 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +""" +Протоколы и интерфейсы для модулей SSH Client. +Обеспечивают единый контракт для SSH-операций и операционных модулей. +""" +from typing import List, Protocol, runtime_checkable + + +@runtime_checkable +class SSHProtocol(Protocol): + """ + Протокол SSH-клиента: connect, cmd, close. + Реализуется SSHBase и SSHClient. + """ + + def connect(self) -> None: + """Подключение к удалённому серверу.""" + ... + + def cmd( + self, + command: str, + sleep: float = 0.1, + out_to_print: bool = False, + suppress_warnings: bool = False, + ) -> List[str]: + """ + Выполнение команды на удалённом сервере. + + Returns: + [stdout, stderr] + """ + ... + + def close(self) -> None: + """Закрытие соединения.""" + ... + + +class SSHOperationsBase: + """ + Базовый класс для операционных модулей (PostgreSQL, 1C, ZFS и т.д.). + Ожидает ssh_client, реализующий SSHProtocol. + """ + + def __init__(self, ssh_client: SSHProtocol) -> None: + """ + Args: + ssh_client: Экземпляр, реализующий SSHProtocol (SSHBase, SSHClient). + """ + self.ssh: SSHProtocol = ssh_client diff --git a/modules/ssh.py b/modules/ssh.py index 0d5b9e5..e660aaf 100755 --- a/modules/ssh.py +++ b/modules/ssh.py @@ -9,10 +9,11 @@ from .postgresql import PostgreSQLOperations from .c1_cluster import C1ClusterOperations -class ssh(SSHBase, PostgreSQLOperations, C1ClusterOperations): +class SSHClient(SSHBase, PostgreSQLOperations, C1ClusterOperations): """ - Класс SSH клиента с поддержкой PostgreSQL и 1С кластера - + SSH-клиент с поддержкой PostgreSQL и кластера 1С. + Рекомендуемый класс для использования (PEP8: PascalCase). + Наследует функциональность от: - SSHBase: базовые SSH операции (connect, cmd, close) - PostgreSQLOperations: операции с PostgreSQL @@ -47,3 +48,7 @@ class ssh(SSHBase, PostgreSQLOperations, C1ClusterOperations): """ self.set_srv_1c(srv_1c) self.set_cluster_credentials(c1_claster_user, c1_claster_pass) + + +# Совместимость: alias для обратной совместимости (legacy) +ssh = SSHClient diff --git a/modules/ssh_base.py b/modules/ssh_base.py index 1d2d481..e594b89 100644 --- a/modules/ssh_base.py +++ b/modules/ssh_base.py @@ -9,17 +9,9 @@ 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) +from .logger import get_logger + +logger = get_logger("ssh_base") # Ожидаемые «ошибки» ZFS при бэкапе — не логируем WARNING при suppress_warnings=True KNOWN_ZFS_ERRORS = ( diff --git a/modules/zfs_backup_ops.py b/modules/zfs_backup_ops.py new file mode 100644 index 0000000..490d9b9 --- /dev/null +++ b/modules/zfs_backup_ops.py @@ -0,0 +1,226 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +""" +Операционный модуль ZFS Backup. +Реализует логику снапшотов, репликации и очистки для удалённых ZFS-пулов. +Используется точкой входа zfs_backup.py (cron/CLI). +""" +import logging +import re +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +from .logger import get_logger +from .protocols import SSHProtocol +from .ssh_base import SSHBase + +logger = get_logger("zfs_backup") + +MAX_RETRIES = 3 +SNAPSHOT_DATE_FMT = "%d-%m-%Y" # dd-mm-yyyy + + +def _execute(ssh: SSHProtocol, command: str, log: logging.Logger) -> Tuple[str, str]: + """Выполнить команду по SSH. При ошибке — исключение.""" + stdout, stderr = ssh.cmd(command) + if stderr and stderr.strip(): + raise RuntimeError(f"Команда завершилась с ошибкой: {stderr.strip()}") + return stdout, stderr + + +def _create_snapshot( + ssh: SSHProtocol, + full_dataset: str, + date_str: str, + log: logging.Logger, +) -> bool: + """Создать ZFS-снапшот. При 'dataset already exists' — успех (skip).""" + cmd = f"/usr/sbin/zfs snapshot {full_dataset}@{date_str}" + stdout, stderr = ssh.cmd(cmd, suppress_warnings=True) + stderr_lower = (stderr or "").lower() + + if "dataset already exists" in stderr_lower: + pool_name = full_dataset.split("/")[0] + snapshot_name = "/".join(full_dataset.split("/")[1:]) + log.info( + "В пуле %s снимок %s@%s уже существует, действие пропущено", + pool_name, + snapshot_name, + date_str, + ) + return True + + if stderr and stderr.strip(): + log.error("❌ Создание снимка failed: %s", stderr.strip()) + return False + + log.info("✅ Снимок %s@%s создан", full_dataset, date_str) + return True + + +def _replicate_snapshot( + ssh: SSHProtocol, + source_dataset: str, + target_dataset: str, + date_str: str, + prev_date: Optional[str], + log: logging.Logger, +) -> bool: + """Репликация снапшота. При 'not an earlier snapshot' — успех (skip).""" + if prev_date: + send_cmd = f"/usr/sbin/zfs send -i @{prev_date} {source_dataset}@{date_str}" + else: + send_cmd = f"/usr/sbin/zfs send {source_dataset}@{date_str}" + full_cmd = f"{send_cmd} | /usr/sbin/zfs recv -F {target_dataset}" + stdout, stderr = ssh.cmd(full_cmd, suppress_warnings=True) + + if stderr and "not an earlier snapshot from the same fs" in stderr: + pool_name = target_dataset.split("/")[0] + dataset_name = "/".join(target_dataset.split("/")[1:]) + log.info("В пуле %s снимок %s@%s уже существует", pool_name, dataset_name, date_str) + return True + + if stderr and stderr.strip(): + log.error("❌ Репликация failed: %s", stderr.strip()) + return False + + log.info("✅ Репликация %s → %s", source_dataset, target_dataset) + return True + + +def _get_previous_snapshot_date(ssh: SSHProtocol, target_dataset: str) -> Optional[str]: + """Дата последнего снапшота target_dataset (dd-mm-yyyy) или None.""" + cmd = f"/usr/sbin/zfs list -t snapshot -H -o name {target_dataset} 2>/dev/null || true" + stdout, _ = ssh.cmd(cmd) + lines = [s.strip() for s in stdout.splitlines() if s.strip()] + pattern = re.compile(r"@(\d{2}-\d{2}-\d{4})$") + dates: List[str] = [] + for line in lines: + m = pattern.search(line) + if m: + dates.append(m.group(1)) + if not dates: + return None + dates.sort(key=lambda s: datetime.strptime(s, "%d-%m-%Y")) + return dates[-1] + + +def _run_with_retry( + log: logging.Logger, + server_name: str, + operation_name: str, + func, + *args, + **kwargs, +) -> Any: + """Выполнить операцию до MAX_RETRIES раз при ошибке.""" + last_exc = None + for attempt in range(1, MAX_RETRIES + 1): + try: + return func(*args, **kwargs) + except Exception as e: + last_exc = e + log.warning("❌ %s: failed %s (retry %s/%s): %s", server_name, operation_name, attempt, MAX_RETRIES, e) + if attempt == MAX_RETRIES: + raise + raise last_exc + + +def backup_server( + server: Dict[str, Any], + ssh_defaults: Dict[str, Any], + log: logging.Logger, +) -> bool: + """ + Выполнить бэкап для одного сервера: снапшоты, send/recv, очистка. + Конфиг v1.1: server['pools'] — список пулов с source_pool, datasets, target_pool. + """ + name = server["name"] + pools = server["pools"] + retention_days = int(server.get("retention_days", 30)) + + port = ssh_defaults.get("port", 22) + username = ssh_defaults.get("username", "root") + pkey_file = ssh_defaults.get("pkey_file", "/root/.ssh/id_rsa") + host_keys = ssh_defaults.get("host_keys", "~/.ssh/known_hosts") + + date_str = datetime.now().strftime(SNAPSHOT_DATE_FMT) + log.info("Сервер %s: дата снапшотов %s", name, date_str) + + ssh = SSHBase(hostname=name, port=port, username=username, pkey_file=pkey_file, host_keys=host_keys) + ssh.connect() + + pool_counts: Dict[str, int] = {} + total_datasets = 0 + + try: + for pool_config in pools: + source_pool = pool_config["source_pool"] + datasets = pool_config["datasets"] + target_pool = pool_config["target_pool"] + pool_counts[source_pool] = len(datasets) + total_datasets += len(datasets) + + for dataset in datasets: + full_dataset = f"{source_pool}/{dataset}" + + def do_snapshot(fd=full_dataset, d=date_str): + if not _create_snapshot(ssh, fd, d, log): + raise RuntimeError(f"Snapshot {fd}@{d} failed") + + _run_with_retry(log, name, f"snapshot {full_dataset}", do_snapshot) + + for pool_config in pools: + source_pool = pool_config["source_pool"] + datasets = pool_config["datasets"] + target_pool = pool_config["target_pool"] + + for dataset in datasets: + full_dataset = f"{source_pool}/{dataset}" + target_dataset = f"{target_pool}/{dataset}" + + def do_send_recv(fd=full_dataset, td=target_dataset, d=date_str): + prev_date = _get_previous_snapshot_date(ssh, td) + if prev_date: + log.info("Инкрементальная передача %s (от %s)", fd, prev_date) + else: + log.info("Полная передача %s", fd) + if not _replicate_snapshot(ssh, fd, td, d, prev_date, log): + raise RuntimeError(f"Replicate {fd}@{d} → {td} failed") + + _run_with_retry(log, name, f"send/recv {full_dataset}", do_send_recv) + + cutoff = datetime.now() - timedelta(days=retention_days) + for pool_config in pools: + source_pool = pool_config["source_pool"] + datasets = pool_config["datasets"] + target_pool = pool_config["target_pool"] + + for dataset in datasets: + for pool, ds in [(source_pool, dataset), (target_pool, dataset)]: + full_ds = f"{pool}/{ds}" + cmd = f"/usr/sbin/zfs list -t snapshot -H -o name {full_ds} 2>/dev/null || true" + stdout, _ = ssh.cmd(cmd) + pattern = re.compile(r"@(\d{2}-\d{2}-\d{4})$") + for line in stdout.splitlines(): + line = line.strip() + if not line: + continue + m = pattern.search(line) + if not m: + continue + try: + snap_date = datetime.strptime(m.group(1), "%d-%m-%Y") + if snap_date < cutoff: + destroy_cmd = f"/usr/sbin/zfs destroy {line}" + _execute(ssh, destroy_cmd, log) + log.info("Удалён старый снапшот: %s", line) + except ValueError: + continue + + parts = ", ".join(f"{p}:{c}" for p, c in sorted(pool_counts.items())) + log.info("✅ %s: %s datasets backed up (%s)", name, total_datasets, parts) + return True + + finally: + ssh.close() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b2c44eb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ssh-client" +version = "0.1.0" +description = "SSH клиент для миграции 1С, ZFS Backup и управления удалёнными серверами" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "MIT"} +authors = [{name = "SSH Client Team"}] +keywords = ["ssh", "1c", "postgresql", "zfs", "backup", "migration"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "paramiko>=2.12.0,<4.0.0", + "PyYAML>=6.0", +] + +[project.optional-dependencies] +dev = ["pytest>=7.0", "ruff>=0.1.0"] + +[project.scripts] +zfs-backup = "zfs_backup:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["modules*"] + +[tool.setuptools.package-dir] +"" = "." diff --git a/requirements.txt b/requirements.txt index ec36f3a..77e280d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,9 +9,12 @@ # SSH клиент для подключения к удаленным серверам paramiko>=2.12.0,<4.0.0 -# ZFS Backup: парсинг конфигурации +# Конфигурация PyYAML>=6.0 +# Telegram интеграция (PRD v1.8) +requests>=2.25.0 + diff --git a/zfs_backup.py b/zfs_backup.py index 3bd95b5..9163542 100755 --- a/zfs_backup.py +++ b/zfs_backup.py @@ -1,70 +1,60 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- """ -Модуль ZFS Backup — централизованное управление ZFS-бэкапами удалённых серверов. -Интегрирован с SSH-модулем. Cron: ежедневно 20:00. +Точка входа ZFS Backup — cron/CLI (PRD v1.8). +Конфигурация: config/config_log.yaml (логирование), config/zfs_backup.yaml (бэкап). -Запуск: python zfs_backup.py --config=config.yaml +Запуск: python zfs_backup.py + или: zfs-backup (после pip install -e .) """ import argparse import logging import os -import re import sys -from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, Optional import yaml -# Добавляем корень проекта в path для импорта modules SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) if SCRIPT_DIR not in sys.path: sys.path.insert(0, SCRIPT_DIR) -from modules.ssh_base import SSHBase +from modules.logger import setup_root_logger, get_logger +from modules.zfs_backup_ops import backup_server, MAX_RETRIES -# Константы DEFAULT_LOG_FILE = "/var/log/zfs_backup.log" -FALLBACK_LOG_FILE = "zfs_backup.log" -MAX_RETRIES = 3 -SNAPSHOT_DATE_FMT = "%d-%m-%Y" # dd-mm-yyyy +CONFIG_LOG_PATH = os.path.join(SCRIPT_DIR, "config", "config_log.yaml") -def setup_logging(log_file: Optional[str] = None) -> logging.Logger: - """Настройка логирования в файл и консоль.""" - logger = logging.getLogger("zfs_backup") - logger.setLevel(logging.INFO) - if logger.handlers: - return logger +def load_log_config(config_path: Optional[str] = None) -> Dict[str, Any]: + """Загрузка config_log.yaml. При отсутствии — возвращает defaults.""" + path = config_path or CONFIG_LOG_PATH + if not os.path.isfile(path): + return {"log_file": DEFAULT_LOG_FILE, "log_level": "INFO"} + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} - formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", + +def setup_logging( + log_file: Optional[str] = None, + log_config_path: Optional[str] = None, +) -> logging.Logger: + """Настройка логирования из config_log.yaml (файл, консоль, Telegram).""" + cfg = load_log_config(log_config_path) + path = log_file or cfg.get("log_file", DEFAULT_LOG_FILE) + if not os.path.isabs(path): + path = os.path.normpath(os.path.join(SCRIPT_DIR, path)) + setup_root_logger( + level=cfg.get("log_level", "INFO"), + log_file=path, + script_dir=SCRIPT_DIR, + telegram_config=cfg.get("telegram"), ) - - # Файл - path = log_file or DEFAULT_LOG_FILE - try: - fh = logging.FileHandler(path, encoding="utf-8") - fh.setFormatter(formatter) - logger.addHandler(fh) - except OSError: - path = os.path.join(SCRIPT_DIR, FALLBACK_LOG_FILE) - fh = logging.FileHandler(path, encoding="utf-8") - fh.setFormatter(formatter) - logger.addHandler(fh) - logger.warning("Не удалось писать в %s, используется %s", DEFAULT_LOG_FILE, path) - - # Консоль - ch = logging.StreamHandler(sys.stdout) - ch.setFormatter(formatter) - logger.addHandler(ch) - - return logger + return get_logger("zfs_backup") def load_config(config_path: str) -> Dict[str, Any]: - """Загрузка конфигурации из YAML → словарь Python (формат v1.1: servers[].pools[]).""" + """Загрузка конфигурации из YAML (формат v1.1: servers[].pools[]).""" with open(config_path, "r", encoding="utf-8") as f: config = yaml.safe_load(f) if not config or "servers" not in config: @@ -79,258 +69,30 @@ def load_config(config_path: str) -> Dict[str, Any]: return config -def execute(ssh: SSHBase, command: str, log: logging.Logger) -> Tuple[str, str]: - """ - Выполнение команды по SSH. При ошибке (stderr или ненулевой код) выбрасывает исключение. - Возвращает (stdout, stderr). - """ - stdout, stderr = ssh.cmd(command) - if stderr and stderr.strip(): - raise RuntimeError(f"Команда завершилась с ошибкой: {stderr.strip()}") - return stdout, stderr - - -def create_snapshot( - ssh: SSHBase, - full_dataset: str, - date_str: str, - log: logging.Logger, -) -> bool: - """ - Создать ZFS-снапшот. При сообщении "dataset already exists" логируем и пропускаем (успех). - Возвращает True при успехе (создан или уже существует), False при ошибке (для retry). - """ - cmd = f"/usr/sbin/zfs snapshot {full_dataset}@{date_str}" - stdout, stderr = ssh.cmd(cmd, suppress_warnings=True) - stderr_lower = (stderr or "").lower() - - if "dataset already exists" in stderr_lower: - pool_name = full_dataset.split("/")[0] - snapshot_name = "/".join(full_dataset.split("/")[1:]) - log.info( - "В пуле %s снимок %s@%s уже существует, действие пропущено", - pool_name, - snapshot_name, - date_str, - ) - return True - - if stderr and stderr.strip(): - log.error("❌ Создание снимка failed: %s", stderr.strip()) - return False - - log.info("✅ Снимок %s@%s создан", full_dataset, date_str) - return True - - -def replicate_snapshot( - ssh: SSHBase, - source_dataset: str, - target_dataset: str, - date_str: str, - prev_date: Optional[str], - log: logging.Logger, -) -> bool: - """ - Репликация снапшота: zfs send [ -i @prev ] source@date | zfs recv target. - При ошибке "not an earlier snapshot from the same fs" логируем и пропускаем (успех). - Возвращает True при успехе (репликация выполнена или снимок уже есть на target), False при ошибке (для retry). - """ - if prev_date: - send_cmd = f"/usr/sbin/zfs send -i @{prev_date} {source_dataset}@{date_str}" - else: - send_cmd = f"/usr/sbin/zfs send {source_dataset}@{date_str}" - full_cmd = f"{send_cmd} | /usr/sbin/zfs recv -F {target_dataset}" - stdout, stderr = ssh.cmd(full_cmd, suppress_warnings=True) - - if stderr and "not an earlier snapshot from the same fs" in stderr: - pool_name = target_dataset.split("/")[0] - dataset_name = "/".join(target_dataset.split("/")[1:]) - log.info( - "В пуле %s снимок %s@%s уже существует", - pool_name, - dataset_name, - date_str, - ) - return True - - if stderr and stderr.strip(): - log.error("❌ Репликация failed: %s", stderr.strip()) - return False - - log.info("✅ Репликация %s → %s", source_dataset, target_dataset) - return True - - -def get_previous_snapshot_date( - ssh: SSHBase, - target_dataset: str, - log: logging.Logger, -) -> Optional[str]: - """ - Получить дату последнего снапшота для target_dataset (формат dd-mm-yyyy). - Если снапшотов нет — возвращает None. - """ - cmd = f"/usr/sbin/zfs list -t snapshot -H -o name {target_dataset} 2>/dev/null || true" - stdout, stderr = ssh.cmd(cmd) - lines = [s.strip() for s in stdout.splitlines() if s.strip()] - pattern = re.compile(r"@(\d{2}-\d{2}-\d{4})$") - dates: List[str] = [] - for line in lines: - m = pattern.search(line) - if m: - dates.append(m.group(1)) - - if not dates: - return None - def parse_d(s: str) -> datetime: - return datetime.strptime(s, "%d-%m-%Y") - dates.sort(key=parse_d) - return dates[-1] - - -def run_with_retry( - log: logging.Logger, - server_name: str, - operation_name: str, - func, - *args, - **kwargs, -) -> Any: - """Выполнить операцию до MAX_RETRIES раз при ошибке.""" - last_exc = None - for attempt in range(1, MAX_RETRIES + 1): - try: - return func(*args, **kwargs) - except Exception as e: - last_exc = e - log.warning( - "❌ %s: failed %s (retry %s/%s): %s", - server_name, - operation_name, - attempt, - MAX_RETRIES, - e, - ) - if attempt == MAX_RETRIES: - raise - raise last_exc - - -def backup_server( - server: Dict[str, Any], - ssh_defaults: Dict[str, Any], - log: logging.Logger, -) -> bool: - """ - Выполнить бэкап для одного сервера: снапшоты по пулам/datasets, send/recv, очистка. - Конфиг v1.1: server['pools'] — список пулов с source_pool, datasets, target_pool. - """ - name = server["name"] - pools = server["pools"] - retention_days = int(server.get("retention_days", 30)) - - port = ssh_defaults.get("port", 22) - username = ssh_defaults.get("username", "root") - pkey_file = ssh_defaults.get("pkey_file", "/root/.ssh/id_rsa") - host_keys = ssh_defaults.get("host_keys", "~/.ssh/known_hosts") - - date_str = datetime.now().strftime(SNAPSHOT_DATE_FMT) - log.info("Сервер %s: дата снапшотов %s", name, date_str) - - ssh = SSHBase(hostname=name, port=port, username=username, pkey_file=pkey_file, host_keys=host_keys) - ssh.connect() - - pool_counts: Dict[str, int] = {} - total_datasets = 0 - - try: - # 1. Фаза снапшотов - for pool_config in pools: - source_pool = pool_config["source_pool"] - datasets = pool_config["datasets"] - target_pool = pool_config["target_pool"] - pool_counts[source_pool] = len(datasets) - total_datasets += len(datasets) - - for dataset in datasets: - full_dataset = f"{source_pool}/{dataset}" - - def do_snapshot(fd=full_dataset, d=date_str): - if not create_snapshot(ssh, fd, d, log): - raise RuntimeError(f"Snapshot {fd}@{d} failed") - - run_with_retry(log, name, f"snapshot {full_dataset}", do_snapshot) - - # 2. Фаза send/recv - for pool_config in pools: - source_pool = pool_config["source_pool"] - datasets = pool_config["datasets"] - target_pool = pool_config["target_pool"] - - for dataset in datasets: - full_dataset = f"{source_pool}/{dataset}" - target_dataset = f"{target_pool}/{dataset}" - - def do_send_recv(fd=full_dataset, td=target_dataset, d=date_str): - prev_date = get_previous_snapshot_date(ssh, td, log) - if prev_date: - log.info("Инкрементальная передача %s (от %s)", fd, prev_date) - else: - log.info("Полная передача %s", fd) - if not replicate_snapshot(ssh, fd, td, d, prev_date, log): - raise RuntimeError(f"Replicate {fd}@{d} → {td} failed") - - run_with_retry(log, name, f"send/recv {full_dataset}", do_send_recv) - - # 3. Cleanup - cutoff = datetime.now() - timedelta(days=retention_days) - for pool_config in pools: - source_pool = pool_config["source_pool"] - datasets = pool_config["datasets"] - target_pool = pool_config["target_pool"] - - for dataset in datasets: - for pool, ds in [(source_pool, dataset), (target_pool, dataset)]: - full_ds = f"{pool}/{ds}" - cmd = f"/usr/sbin/zfs list -t snapshot -H -o name {full_ds} 2>/dev/null || true" - stdout, _ = ssh.cmd(cmd) - pattern = re.compile(r"@(\d{2}-\d{2}-\d{4})$") - for line in stdout.splitlines(): - line = line.strip() - if not line: - continue - m = pattern.search(line) - if not m: - continue - try: - snap_date = datetime.strptime(m.group(1), "%d-%m-%Y") - if snap_date < cutoff: - destroy_cmd = f"/usr/sbin/zfs destroy {line}" - execute(ssh, destroy_cmd, log) - log.info("Удалён старый снапшот: %s", line) - except ValueError: - continue - - parts = ", ".join(f"{p}:{c}" for p, c in sorted(pool_counts.items())) - log.info("✅ %s: %s datasets backed up (%s)", name, total_datasets, parts) - return True - - finally: - ssh.close() - - def main() -> int: parser = argparse.ArgumentParser(description="ZFS Backup — бэкап ZFS датасетов по config.yaml") - parser.add_argument("--config", default="config.yaml", help="Путь к config.yaml") - parser.add_argument("--log-file", default=None, help="Путь к лог-файлу (по умолчанию /var/log/zfs_backup.log)") + parser.add_argument( + "--config", + default="config/zfs_backup.yaml", + help="Путь к config (по умолчанию config/zfs_backup.yaml)", + ) + parser.add_argument( + "--log-file", + default=None, + help="Путь к лог-файлу (переопределяет config/config_log.yaml)", + ) + parser.add_argument( + "--log-config", + default=None, + help="Путь к config_log.yaml (по умолчанию config/config_log.yaml)", + ) args = parser.parse_args() - log = setup_logging(args.log_file) + log = setup_logging(log_file=args.log_file, log_config_path=args.log_config) config_path = args.config if not os.path.isabs(config_path): - config_path = os.path.join(SCRIPT_DIR, config_path) + config_path = os.path.normpath(os.path.join(SCRIPT_DIR, config_path)) if not os.path.isfile(config_path): log.error("Файл конфигурации не найден: %s", config_path) return 1 @@ -348,7 +110,12 @@ def main() -> int: try: backup_server(server, ssh_defaults, log) except Exception as e: - log.exception("❌ %s: бэкап завершился ошибкой после %s попыток: %s", server["name"], MAX_RETRIES, e) + log.exception( + "❌ %s: бэкап завершился ошибкой после %s попыток: %s", + server["name"], + MAX_RETRIES, + e, + ) return 1 return 0