Логи и конфиги переработаны, добавлен модуль ZFS и ведется работа с телеграм-ботом

This commit is contained in:
2026-02-14 19:20:55 +03:00
parent 40bf9f9887
commit f227824070
21 changed files with 1135 additions and 689 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ lib/
lib64
include/
__pycache__/
logs/

View File

@@ -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
```

112
config.py
View File

@@ -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

View File

@@ -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

81
config/__init__.py Normal file
View File

@@ -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)

32
config/config_log.yaml Normal file
View File

@@ -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

41
config/migration.yaml Normal file
View File

@@ -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

23
config/zfs_backup.yaml Normal file
View File

@@ -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

View File

@@ -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'],
)
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']
c1_cluster = C1ClusterOperations(ssh_client, lxc_container, cluster_user, cluster_password)
# Получаем версию кластера
version = c1_cluster.cluster_version()
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'),
)
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']
c1_cluster = C1ClusterOperations(ssh_client, lxc_container, cluster_user, cluster_password)
# Получаем список баз данных
bases = c1_cluster.base_list()
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,7 +170,7 @@ 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']
@@ -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")
# Раскомментируйте нужные примеры для запуска

View File

@@ -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'],
)
client.connect()
# Подключаемся
ssh_client.connect()
# Создаем экземпляр модуля PostgreSQL
pg = PostgreSQLOperations(ssh_client)
# Получаем список баз данных
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)
client.bases_list_print(srv_pgsql)
ssh_client.close()
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)
client.bases_size_print(srv_pgsql)
ssh_client.close()
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']}' создан успешно")
@@ -157,7 +134,7 @@ def example_backup_single_base():
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,15 +244,14 @@ 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,15 +297,14 @@ 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']
@@ -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,15 +364,14 @@ 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}'
@@ -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")

View File

@@ -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',
]

View File

@@ -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:
"""

283
modules/logger.py Normal file
View File

@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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}")

View File

@@ -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):
"""
Инициализация модуля PostgreSQL
def __init__(self, ssh_client) -> None:
"""
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]:
"""

52
modules/protocols.py Normal file
View File

@@ -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

View File

@@ -9,9 +9,10 @@ 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)
@@ -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

View File

@@ -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 = (

226
modules/zfs_backup_ops.py Normal file
View File

@@ -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()

41
pyproject.toml Normal file
View File

@@ -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]
"" = "."

View File

@@ -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

View File

@@ -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