migration refactor

This commit is contained in:
2025-08-16 12:29:37 +03:00
parent f368f5ced9
commit 0e2fc14732
16 changed files with 1977 additions and 2761 deletions

View File

@@ -1,288 +1,146 @@
# [MODULE] Superset Dashboard Backup Script
# @contract: Автоматизирует процесс резервного копирования дашбордов Superset из различных окружений.
# @semantic_layers:
# 1. Инициализация логгера и клиентов Superset.
# 2. Выполнение бэкапа для каждого окружения (DEV, SBX, PROD).
# 3. Формирование итогового отчета.
# @coherence:
# - Использует `SupersetClient` для взаимодействия с API Superset.
# - Использует `SupersetLogger` для централизованного логирования.
# - Работает с `Pathlib` для управления файлами и директориями.
# - Интегрируется с `keyring` для безопасного хранения паролей.
# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument,invalid-name,redefined-outer-name
"""
[MODULE] Superset Dashboard Backup Script
@contract: Автоматизирует процесс резервного копирования дашбордов Superset.
"""
# [IMPORTS] Стандартная библиотека
import logging
from datetime import datetime
import shutil
import os
import sys
from pathlib import Path
from dataclasses import dataclass
# [IMPORTS] Сторонние библиотеки
import keyring
# [IMPORTS] Third-party
from requests.exceptions import RequestException
# [IMPORTS] Локальные модули
from superset_tool.models import SupersetConfig
from superset_tool.client import SupersetClient
from superset_tool.exceptions import SupersetAPIError
from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.fileio import save_and_unpack_dashboard, archive_exports, sanitize_filename,consolidate_archive_folders,remove_empty_directories
from superset_tool.utils.fileio import (
save_and_unpack_dashboard,
archive_exports,
sanitize_filename,
consolidate_archive_folders,
remove_empty_directories
)
from superset_tool.utils.init_clients import setup_clients
# [COHERENCE_CHECK_PASSED] Все необходимые модули импортированы и согласованы.
# [ENTITY: Dataclass('BackupConfig')]
# CONTRACT:
# PURPOSE: Хранит конфигурацию для процесса бэкапа.
@dataclass
class BackupConfig:
"""Конфигурация для процесса бэкапа."""
consolidate: bool = True
rotate_archive: bool = True
clean_folders: bool = True
# [FUNCTION] backup_dashboards
def backup_dashboards(client: SupersetClient,
env_name: str,
backup_root: Path,
logger: SupersetLogger,
consolidate: bool = True,
rotate_archive: bool = True,
clean_folders:bool = True) -> bool:
""" [CONTRACT] Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения.
@pre:
- `client` должен быть инициализированным экземпляром `SupersetClient`.
- `env_name` должен быть строкой, обозначающей окружение.
- `backup_root` должен быть валидным путем к корневой директории бэкапа.
- `logger` должен быть инициализирован.
@post:
- Дашборды экспортируются и сохраняются в поддиректориях `backup_root/env_name/dashboard_title`.
- Старые экспорты архивируются.
- Возвращает `True` если все дашборды были экспортированы без критических ошибок, `False` иначе.
@side_effects:
- Создает директории и файлы в файловой системе.
- Логирует статус выполнения, успешные экспорты и ошибки.
@exceptions:
- `SupersetAPIError`, `NetworkError`, `DashboardNotFoundError`, `ExportError` могут быть подняты методами `SupersetClient` и будут логированы."""
# [ANCHOR] DASHBOARD_BACKUP_PROCESS
logger.info(f"[INFO] Запуск бэкапа дашбордов для окружения: {env_name}")
logger.debug(
"[PARAMS] Флаги: consolidate=%s, rotate_archive=%s, clean_folders=%s",
extra={
"consolidate": consolidate,
"rotate_archive": rotate_archive,
"clean_folders": clean_folders,
"env": env_name
}
)
# [ENTITY: Function('backup_dashboards')]
# CONTRACT:
# PURPOSE: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения.
# PRECONDITIONS:
# - `client` должен быть инициализированным экземпляром `SupersetClient`.
# - `env_name` должен быть строкой, обозначающей окружение.
# - `backup_root` должен быть валидным путем к корневой директории бэкапа.
# POSTCONDITIONS:
# - Дашборды экспортируются и сохраняются.
# - Возвращает `True` если все дашборды были экспортированы без критических ошибок, `False` иначе.
def backup_dashboards(
client: SupersetClient,
env_name: str,
backup_root: Path,
logger: SupersetLogger,
config: BackupConfig
) -> bool:
logger.info(f"[STATE][backup_dashboards][ENTER] Starting backup for {env_name}.")
try:
dashboard_count, dashboard_meta = client.get_dashboards()
logger.info(f"[INFO] Найдено {dashboard_count} дашбордов для экспорта в {env_name}")
logger.info(f"[STATE][backup_dashboards][PROGRESS] Found {dashboard_count} dashboards to export in {env_name}.")
if dashboard_count == 0:
logger.warning(f"[WARN] Нет дашбордов для экспорта в {env_name}. Процесс завершен.")
return True
success_count = 0
error_details = []
for db in dashboard_meta:
dashboard_id = db.get('id')
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
dashboard_slug = db.get('slug', 'unknown-slug') # Используем slug для уникальности
# [PRECONDITION] Проверка наличия ID и slug
if not dashboard_id or not dashboard_slug:
logger.warning(
f"[SKIP] Пропущен дашборд с неполными метаданными: {dashboard_title} (ID: {dashboard_id}, Slug: {dashboard_slug})",
extra={'dashboard_meta': db}
)
if not dashboard_id:
continue
logger.debug(f"[DEBUG] Попытка экспорта дашборда: '{dashboard_title}' (ID: {dashboard_id})")
try:
# [ANCHOR] CREATE_DASHBOARD_DIR
# Используем slug в пути для большей уникальности и избежания конфликтов имен
dashboard_base_dir_name = sanitize_filename(f"{dashboard_title}")
dashboard_dir = backup_root / env_name / dashboard_base_dir_name
dashboard_dir.mkdir(parents=True, exist_ok=True)
logger.debug(f"[DEBUG] Директория для дашборда: {dashboard_dir}")
# [ANCHOR] EXPORT_DASHBOARD_ZIP
zip_content, filename = client.export_dashboard(dashboard_id)
# [ANCHOR] SAVE_AND_UNPACK
# Сохраняем только ZIP-файл, распаковка здесь не нужна для бэкапа
save_and_unpack_dashboard(
zip_content=zip_content,
original_filename=filename,
output_dir=dashboard_dir,
unpack=False, # Только сохраняем ZIP, не распаковываем для бэкапа
unpack=False,
logger=logger
)
logger.info(f"[INFO] Дашборд '{dashboard_title}' (ID: {dashboard_id}) успешно экспортирован.")
if rotate_archive:
# [ANCHOR] ARCHIVE_OLD_BACKUPS
try:
archive_exports(
str(dashboard_dir),
daily_retention=7, # Сохранять последние 7 дней
weekly_retention=2, # Сохранять последние 2 недели
monthly_retention=3, # Сохранять последние 3 месяца
logger=logger,
deduplicate=True
)
logger.debug(f"[DEBUG] Старые экспорты для '{dashboard_title}' архивированы.")
except Exception as cleanup_error:
logger.warning(
f"[WARN] Ошибка архивирования старых бэкапов для '{dashboard_title}': {cleanup_error}",
exc_info=False # Не показываем полный traceback для очистки, т.к. это второстепенно
)
if config.rotate_archive:
archive_exports(str(dashboard_dir), logger=logger)
success_count += 1
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
logger.error(f"[STATE][backup_dashboards][FAILURE] Failed to export dashboard {dashboard_title}: {db_error}", exc_info=True)
if config.consolidate:
consolidate_archive_folders(backup_root / env_name , logger=logger)
except Exception as db_error:
error_info = {
'dashboard_id': dashboard_id,
'dashboard_title': dashboard_title,
'error_message': str(db_error),
'env': env_name,
'error_type': type(db_error).__name__
}
error_details.append(error_info)
logger.error(
f"[ERROR] Ошибка экспорта дашборда '{dashboard_title}' (ID: {dashboard_id})",
extra=error_info, exc_info=True # Логируем полный traceback для ошибок экспорта
)
if config.clean_folders:
remove_empty_directories(str(backup_root / env_name), logger=logger)
if consolidate:
# [ANCHOR] Объединяем архивы по SLUG в одну папку с максимальной датой
try:
consolidate_archive_folders(backup_root / env_name , logger=logger)
logger.debug(f"[DEBUG] Файлы для '{dashboard_title}' консолидированы.")
except Exception as consolidate_error:
logger.warning(
f"[WARN] Ошибка консолидации файлов для '{backup_root / env_name}': {consolidate_error}",
exc_info=False # Не показываем полный traceback для консолидации, т.к. это второстепенно
)
if clean_folders:
# [ANCHOR] Удаляем пустые папки
try:
dirs_count = remove_empty_directories(str(backup_root / env_name), logger=logger)
logger.debug(f"[DEBUG] {dirs_count} пустых папок в '{backup_root / env_name }' удалены.")
except Exception as clean_error:
logger.warning(
f"[WARN] Ошибка очистки пустых директорий в '{backup_root / env_name}': {clean_error}",
exc_info=False # Не показываем полный traceback для консолидации, т.к. это второстепенно
)
if error_details:
logger.error(
f"[COHERENCE_CHECK_FAILED] Итоги экспорта для {env_name}:",
extra={'success_count': success_count, 'errors': error_details, 'total_dashboards': dashboard_count}
)
return False
else:
logger.info(
f"[COHERENCE_CHECK_PASSED] Все {success_count} дашбордов для {env_name} успешно экспортированы."
)
return True
except Exception as e:
logger.critical(
f"[CRITICAL] Фатальная ошибка бэкапа для окружения {env_name}: {str(e)}",
exc_info=True
)
return success_count == dashboard_count
except (RequestException, IOError) as e:
logger.critical(f"[STATE][backup_dashboards][FAILURE] Fatal error during backup for {env_name}: {e}", exc_info=True)
return False
# END_FUNCTION_backup_dashboards
# [FUNCTION] main
# @contract: Основная точка входа скрипта.
# @semantic: Координирует инициализацию, выполнение бэкапа и логирование результатов.
# @post:
# - Возвращает 0 при успешном выполнении, 1 при фатальной ошибке.
# @side_effects:
# - Инициализирует логгер.
# - Вызывает `setup_clients` и `backup_dashboards`.
# - Записывает логи в файл и выводит в консоль.
# [ENTITY: Function('main')]
# CONTRACT:
# PURPOSE: Основная точка входа скрипта.
# PRECONDITIONS: None
# POSTCONDITIONS: Возвращает код выхода.
def main() -> int:
"""Основная функция выполнения бэкапа"""
# [ANCHOR] MAIN_EXECUTION_START
# [CONFIG] Инициализация логгера
# @invariant: Логгер должен быть доступен на протяжении всей работы скрипта.
log_dir = Path("P:\\Superset\\010 Бекапы\\Logs") # [COHERENCE_NOTE] Убедитесь, что путь доступен.
logger = SupersetLogger(
log_dir=log_dir,
level=logging.INFO,
console=True
)
logger.info("="*50)
logger.info("[INFO] Запуск процесса бэкапа Superset")
logger.info("="*50)
exit_code = 0 # [STATE] Код выхода скрипта
log_dir = Path("P:\\Superset\\010 Бекапы\\Logs")
logger = SupersetLogger(log_dir=log_dir, level=logging.INFO, console=True)
logger.info("[STATE][main][ENTER] Starting Superset backup process.")
exit_code = 0
try:
# [ANCHOR] CLIENT_SETUP
clients = setup_clients(logger)
# [CONFIG] Определение корневой директории для бэкапов
# @invariant: superset_backup_repo должен быть доступен для записи.
superset_backup_repo = Path("P:\\Superset\\010 Бекапы")
superset_backup_repo.mkdir(parents=True, exist_ok=True) # Гарантируем существование директории
logger.info(f"[INFO] Корневая директория бэкапов: {superset_backup_repo}")
# [ANCHOR] BACKUP_DEV_ENVIRONMENT
dev_success = backup_dashboards(
clients['dev'],
"DEV",
superset_backup_repo,
rotate_archive=True,
logger=logger
)
# [ANCHOR] BACKUP_SBX_ENVIRONMENT
sbx_success = backup_dashboards(
clients['sbx'],
"SBX",
superset_backup_repo,
rotate_archive=True,
logger=logger
)
# [ANCHOR] BACKUP_PROD_ENVIRONMENT
prod_success = backup_dashboards(
clients['prod'],
"PROD",
superset_backup_repo,
rotate_archive=True,
logger=logger
)
superset_backup_repo.mkdir(parents=True, exist_ok=True)
# [ANCHOR] BACKUP_PROD_ENVIRONMENT
preprod_success = backup_dashboards(
clients['preprod'],
"PREPROD",
superset_backup_repo,
rotate_archive=True,
logger=logger
)
# [ANCHOR] FINAL_REPORT
# [INFO] Итоговый отчет о выполнении бэкапа
logger.info("="*50)
logger.info("[INFO] Итоги выполнения бэкапа:")
logger.info(f"[INFO] DEV: {'Успешно' if dev_success else 'С ошибками'}")
logger.info(f"[INFO] SBX: {'Успешно' if sbx_success else 'С ошибками'}")
logger.info(f"[INFO] PROD: {'Успешно' if prod_success else 'С ошибками'}")
logger.info(f"[INFO] PREPROD: {'Успешно' if preprod_success else 'С ошибками'}")
logger.info(f"[INFO] Полный лог доступен в: {log_dir}")
results = {}
environments = ['dev', 'sbx', 'prod', 'preprod']
backup_config = BackupConfig(rotate_archive=True)
if not (dev_success and sbx_success and prod_success):
for env in environments:
results[env] = backup_dashboards(
clients[env],
env.upper(),
superset_backup_repo,
logger=logger,
config=backup_config
)
if not all(results.values()):
exit_code = 1
logger.warning("[COHERENCE_CHECK_FAILED] Бэкап завершен с ошибками в одном или нескольких окружениях.")
else:
logger.info("[COHERENCE_CHECK_PASSED] Все бэкапы успешно завершены без ошибок.")
except Exception as e:
logger.critical(f"[CRITICAL] Фатальная ошибка выполнения скрипта: {str(e)}", exc_info=True)
except (RequestException, IOError) as e:
logger.critical(f"[STATE][main][FAILURE] Fatal error in main execution: {e}", exc_info=True)
exit_code = 1
logger.info("[INFO] Процесс бэкапа завершен")
return exit_code
# [ENTRYPOINT] Главная точка запуска скрипта
logger.info("[STATE][main][SUCCESS] Superset backup process finished.")
return exit_code
# END_FUNCTION_main
if __name__ == "__main__":
exit_code = main()
exit(exit_code)
sys.exit(main())