#!/usr/bin/python3 # -*- coding: utf-8 -*- """ Точка входа ZFS Backup — cron/CLI (PRD v1.8). Конфигурация: config/config_log.yaml (логирование), config/zfs_backup.yaml (бэкап). Запуск: python zfs_backup.py или: zfs-backup (после pip install -e .) """ import argparse import logging import os import sys from typing import Any, Dict, Optional import yaml SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) if SCRIPT_DIR not in sys.path: sys.path.insert(0, SCRIPT_DIR) 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" CONFIG_LOG_PATH = os.path.join(SCRIPT_DIR, "config", "config_log.yaml") 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 {} 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"), ) return get_logger("zfs_backup") def load_config(config_path: str) -> Dict[str, Any]: """Загрузка конфигурации из 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: raise ValueError("В config.yaml должен быть раздел 'servers'") for i, srv in enumerate(config["servers"]): if "pools" not in srv or not srv["pools"]: raise ValueError(f"Сервер [{i}] (name={srv.get('name')}) должен содержать непустой раздел 'pools'") for j, pool in enumerate(srv["pools"]): for key in ("source_pool", "target_pool", "datasets"): if key not in pool: raise ValueError(f"Сервер [{i}] pool [{j}]: отсутствует поле '{key}'") return config def main() -> int: parser = argparse.ArgumentParser(description="ZFS Backup — бэкап ZFS датасетов по config.yaml") 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(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.normpath(os.path.join(SCRIPT_DIR, config_path)) if not os.path.isfile(config_path): log.error("Файл конфигурации не найден: %s", config_path) return 1 try: config = load_config(config_path) except Exception as e: log.exception("Ошибка загрузки config.yaml: %s", e) return 1 ssh_defaults = config.get("ssh_defaults") or {} servers = config["servers"] for server in servers: try: backup_server(server, ssh_defaults, log) except Exception as e: log.exception( "❌ %s: бэкап завершился ошибкой после %s попыток: %s", server["name"], MAX_RETRIES, e, ) return 1 return 0 if __name__ == "__main__": sys.exit(main())