# [MODULE_PATH] superset_tool.migration_script # [FILE] migration_script.py # [SEMANTICS] migration, cli, superset, ui, logging, fallback, error-recovery, non-interactive, temp-files, batch-delete # -------------------------------------------------------------- # [IMPORTS] # -------------------------------------------------------------- import json import logging import sys import zipfile from pathlib import Path from typing import List, Optional, Tuple, Dict from superset_tool.client import SupersetClient from superset_tool.utils.init_clients import setup_clients from superset_tool.utils.fileio import ( create_temp_file, # новый контекстный менеджер update_yamls, create_dashboard_export, ) from superset_tool.utils.whiptail_fallback import ( menu, checklist, yesno, msgbox, inputbox, gauge, ) from superset_tool.utils.logger import SupersetLogger # type: ignore # [END_IMPORTS] # -------------------------------------------------------------- # [ENTITY: Service('Migration')] # [RELATION: Service('Migration')] -> [DEPENDS_ON] -> [PythonModule('superset_tool.client')] # -------------------------------------------------------------- """ :purpose: Интерактивный процесс миграции дашбордов с возможностью «удалить‑и‑перезаписать» при ошибке импорта. :preconditions: - Конфигурация Superset‑клиентов доступна, - Пользователь может взаимодействовать через консольный UI. :postconditions: - Выбранные дашборды импортированы в целевое окружение. :sideeffect: Записывает журнал в каталог ``logs/`` текущего рабочего каталога. """ class Migration: """ :ivar SupersetLogger logger: Логгер. :ivar bool enable_delete_on_failure: Флаг «удалять‑при‑ошибке». :ivar SupersetClient from_c: Клиент‑источник. :ivar SupersetClient to_c: Клиент‑назначение. :ivar List[dict] dashboards_to_migrate: Список выбранных дашбордов. :ivar Optional[dict] db_config_replacement: Параметры замены имён БД. :ivar List[dict] _failed_imports: Внутренний буфер неудавшихся импортов (ключи: slug, zip_content, dash_id). """ # -------------------------------------------------------------- # [ENTITY: Method('__init__')] # -------------------------------------------------------------- """ :purpose: Создать сервис миграции и настроить логгер. :preconditions: None. :postconditions: ``self.logger`` готов к использованию; ``enable_delete_on_failure`` = ``False``. """ def __init__(self) -> None: default_log_dir = Path.cwd() / "logs" self.logger = SupersetLogger( name="migration_script", log_dir=default_log_dir, level=logging.INFO, console=True, ) self.enable_delete_on_failure = False self.from_c: Optional[SupersetClient] = None self.to_c: Optional[SupersetClient] = None self.dashboards_to_migrate: List[dict] = [] self.db_config_replacement: Optional[dict] = None self._failed_imports: List[dict] = [] # <-- буфер ошибок assert self.logger is not None, "Logger must be instantiated." # [END_ENTITY] # -------------------------------------------------------------- # [ENTITY: Method('run')] # -------------------------------------------------------------- """ :purpose: Точка входа – последовательный запуск всех шагов миграции. :preconditions: Логгер готов. :postconditions: Скрипт завершён, пользователю выведено сообщение. """ def run(self) -> None: self.logger.info("[INFO][run][ENTER] Запуск скрипта миграции.") self.ask_delete_on_failure() self.select_environments() self.select_dashboards() self.confirm_db_config_replacement() self.execute_migration() self.logger.info("[INFO][run][EXIT] Скрипт миграции завершён.") # [END_ENTITY] # -------------------------------------------------------------- # [ENTITY: Method('ask_delete_on_failure')] # -------------------------------------------------------------- """ :purpose: Запросить у пользователя, следует ли удалять дашборд при ошибке импорта. :preconditions: None. :postconditions: ``self.enable_delete_on_failure`` установлен. """ def ask_delete_on_failure(self) -> None: self.enable_delete_on_failure = yesno( "Поведение при ошибке импорта", "Если импорт завершится ошибкой, удалить существующий дашборд и попытаться импортировать заново?", ) self.logger.info( "[INFO][ask_delete_on_failure] Delete‑on‑failure = %s", self.enable_delete_on_failure, ) # [END_ENTITY] # -------------------------------------------------------------- # [ENTITY: Method('select_environments')] # -------------------------------------------------------------- """ :purpose: Выбрать исходное и целевое окружения Superset. :preconditions: ``setup_clients`` успешно инициализирует все клиенты. :postconditions: ``self.from_c`` и ``self.to_c`` установлены. """ def select_environments(self) -> None: self.logger.info("[INFO][select_environments][ENTER] Шаг 1/5: Выбор окружений.") try: all_clients = setup_clients(self.logger) available_envs = list(all_clients.keys()) except Exception as e: self.logger.error("[ERROR][select_environments] %s", e, exc_info=True) msgbox("Ошибка", "Не удалось инициализировать клиенты.") return rc, from_env_name = menu( title="Выбор окружения", prompt="Исходное окружение:", choices=available_envs, ) if rc != 0: return self.from_c = all_clients[from_env_name] self.logger.info("[INFO][select_environments] from = %s", from_env_name) available_envs.remove(from_env_name) rc, to_env_name = menu( title="Выбор окружения", prompt="Целевое окружение:", choices=available_envs, ) if rc != 0: return self.to_c = all_clients[to_env_name] self.logger.info("[INFO][select_environments] to = %s", to_env_name) self.logger.info("[INFO][select_environments][EXIT] Шаг 1 завершён.") # [END_ENTITY] # -------------------------------------------------------------- # [ENTITY: Method('select_dashboards')] # -------------------------------------------------------------- """ :purpose: Позволить пользователю выбрать набор дашбордов для миграции. :preconditions: ``self.from_c`` инициализирован. :postconditions: ``self.dashboards_to_migrate`` заполнен. """ def select_dashboards(self) -> None: self.logger.info("[INFO][select_dashboards][ENTER] Шаг 2/5: Выбор дашбордов.") try: _, all_dashboards = self.from_c.get_dashboards() # type: ignore[attr-defined] if not all_dashboards: self.logger.warning("[WARN][select_dashboards] No dashboards.") msgbox("Информация", "В исходном окружении нет дашбордов.") return options = [("ALL", "Все дашборды")] + [ (str(d["id"]), d["dashboard_title"]) for d in all_dashboards ] rc, selected = checklist( title="Выбор дашбордов", prompt="Отметьте нужные дашборды (введите номера):", options=options, ) if rc != 0: return if "ALL" in selected: self.dashboards_to_migrate = list(all_dashboards) self.logger.info( "[INFO][select_dashboards] Выбраны все дашборды (%d).", len(self.dashboards_to_migrate), ) return self.dashboards_to_migrate = [ d for d in all_dashboards if str(d["id"]) in selected ] self.logger.info( "[INFO][select_dashboards] Выбрано %d дашбордов.", len(self.dashboards_to_migrate), ) except Exception as e: self.logger.error("[ERROR][select_dashboards] %s", e, exc_info=True) msgbox("Ошибка", "Не удалось получить список дашбордов.") self.logger.info("[INFO][select_dashboards][EXIT] Шаг 2 завершён.") # [END_ENTITY] # -------------------------------------------------------------- # [ENTITY: Method('confirm_db_config_replacement')] # -------------------------------------------------------------- """ :purpose: Запросить у пользователя, требуется ли заменить имена БД в YAML‑файлах. :preconditions: None. :postconditions: ``self.db_config_replacement`` либо ``None``, либо заполнен. """ def confirm_db_config_replacement(self) -> None: if yesno("Замена БД", "Заменить конфигурацию БД в YAML‑файлах?"): rc, old_name = inputbox("Замена БД", "Старое имя БД (например, db_dev):") if rc != 0: return rc, new_name = inputbox("Замена БД", "Новое имя БД (например, db_prod):") if rc != 0: return self.db_config_replacement = { "old": {"database_name": old_name}, "new": {"database_name": new_name}, } self.logger.info( "[INFO][confirm_db_config_replacement] Replacement set: %s", self.db_config_replacement, ) else: self.logger.info("[INFO][confirm_db_config_replacement] Skipped.") # [END_ENTITY] # -------------------------------------------------------------- # [ENTITY: Method('_batch_delete_by_ids')] # -------------------------------------------------------------- """ :purpose: Удалить набор дашбордов по их ID единым запросом. :preconditions: - ``ids`` – непустой список целых чисел. :postconditions: Все указанные дашборды удалены (если они существовали). :sideeffect: Делает HTTP‑запрос ``DELETE /dashboard/?q=[ids]``. """ def _batch_delete_by_ids(self, ids: List[int]) -> None: if not ids: self.logger.debug("[DEBUG][_batch_delete_by_ids] Empty ID list – nothing to delete.") return self.logger.info("[INFO][_batch_delete_by_ids] Deleting dashboards IDs: %s", ids) # Формируем параметр q в виде JSON‑массива, как требует Superset. q_param = json.dumps(ids) response = self.to_c.network.request( method="DELETE", endpoint="/dashboard/", params={"q": q_param}, ) # Superset обычно отвечает 200/204; проверяем поле ``result`` при наличии. if isinstance(response, dict) and response.get("result", True) is False: self.logger.warning("[WARN][_batch_delete_by_ids] Unexpected delete response: %s", response) else: self.logger.info("[INFO][_batch_delete_by_ids] Delete request completed.") # [END_ENTITY] # -------------------------------------------------------------- # [ENTITY: Method('execute_migration')] # -------------------------------------------------------------- """ :purpose: Выполнить экспорт‑импорт выбранных дашбордов, при необходимости обновив YAML‑файлы. При ошибке импортов сохраняем slug, а потом удаляем проблемные дашборды **по ID**, получив их через slug. :preconditions: - ``self.dashboards_to_migrate`` не пуст, - ``self.from_c`` и ``self.to_c`` инициализированы. :postconditions: - Все успешные дашборды импортированы, - Неудачные дашборды, если пользователь выбрал «удалять‑при‑ошибке», удалены и повторно импортированы. :sideeffect: При включённом флаге ``enable_delete_on_failure`` производится батч‑удаление и повторный импорт. """ def execute_migration(self) -> None: if not self.dashboards_to_migrate: self.logger.warning("[WARN][execute_migration] No dashboards to migrate.") msgbox("Информация", "Нет дашбордов для миграции.") return total = len(self.dashboards_to_migrate) self.logger.info("[INFO][execute_migration] Starting migration of %d dashboards.", total) # Передаём режим клиенту‑назначению self.to_c.delete_before_reimport = self.enable_delete_on_failure # type: ignore[attr-defined] # ----------------------------------------------------------------- # 1️⃣ Основной проход – экспорт → импорт → сбор ошибок # ----------------------------------------------------------------- with gauge("Миграция...", width=60, height=10) as g: for i, dash in enumerate(self.dashboards_to_migrate): dash_id = dash["id"] dash_slug = dash.get("slug") # slug нужен для дальнейшего поиска title = dash["dashboard_title"] progress = int((i / total) * 100) g.set_text(f"Миграция: {title} ({i + 1}/{total})") g.set_percent(progress) try: # ------------------- Экспорт ------------------- exported_content, _ = self.from_c.export_dashboard(dash_id) # type: ignore[attr-defined] # ------------------- Временный ZIP ------------------- with create_temp_file( content=exported_content, suffix=".zip", logger=self.logger, ) as tmp_zip_path: self.logger.debug("[DEBUG][temp_zip] Temporary ZIP at %s", tmp_zip_path) # ------------------- Распаковка во временный каталог ------------------- with create_temp_file(suffix=".dir", logger=self.logger) as tmp_unpack_dir: self.logger.debug("[DEBUG][temp_dir] Temporary unpack dir: %s", tmp_unpack_dir) with zipfile.ZipFile(tmp_zip_path, "r") as zip_ref: zip_ref.extractall(tmp_unpack_dir) self.logger.info("[INFO][execute_migration] Export unpacked to %s", tmp_unpack_dir) # ------------------- YAML‑обновление (если нужно) ------------------- if self.db_config_replacement: update_yamls( db_configs=[self.db_config_replacement], path=str(tmp_unpack_dir), ) self.logger.info("[INFO][execute_migration] YAML‑files updated.") # ------------------- Сборка нового ZIP ------------------- with create_temp_file(suffix=".zip", logger=self.logger) as tmp_new_zip: create_dashboard_export( zip_path=tmp_new_zip, source_paths=[str(tmp_unpack_dir)], ) self.logger.info("[INFO][execute_migration] Re‑packed to %s", tmp_new_zip) # ------------------- Импорт ------------------- self.to_c.import_dashboard( file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug, ) # type: ignore[attr-defined] # Если импорт прошёл без исключений – фиксируем успех self.logger.info("[INFO][execute_migration][SUCCESS] Dashboard %s imported.", title) except Exception as exc: # Сохраняем данные для повторного импорта после batch‑удаления self.logger.error("[ERROR][execute_migration] %s", exc, exc_info=True) self._failed_imports.append( { "slug": dash_slug, "dash_id": dash_id, "zip_content": exported_content, } ) msgbox("Ошибка", f"Не удалось мигрировать дашборд {title}.\n\n{exc}") g.set_percent(100) # ----------------------------------------------------------------- # 2️⃣ Если возникли ошибки и пользователь согласился удалять – удаляем и повторяем # ----------------------------------------------------------------- if self.enable_delete_on_failure and self._failed_imports: self.logger.info( "[INFO][execute_migration] %d dashboards failed. Starting recovery procedure.", len(self._failed_imports), ) # ------------------- Получаем список дашбордов в целевом окружении ------------------- _, target_dashboards = self.to_c.get_dashboards() # type: ignore[attr-defined] slug_to_id: Dict[str, int] = { d["slug"]: d["id"] for d in target_dashboards if "slug" in d and "id" in d } # ------------------- Формируем список ID‑ов для удаления ------------------- ids_to_delete: List[int] = [] for fail in self._failed_imports: slug = fail["slug"] if slug and slug in slug_to_id: ids_to_delete.append(slug_to_id[slug]) else: self.logger.warning( "[WARN][execute_migration] Unable to map slug '%s' to ID on target.", slug, ) # ------------------- Batch‑удаление ------------------- self._batch_delete_by_ids(ids_to_delete) # ------------------- Повторный импорт только для проблемных дашбордов ------------------- for fail in self._failed_imports: dash_slug = fail["slug"] dash_id = fail["dash_id"] zip_content = fail["zip_content"] # Один раз создаём временный ZIP‑файл из сохранённого содержимого with create_temp_file( content=zip_content, suffix=".zip", logger=self.logger, ) as retry_zip_path: self.logger.debug("[DEBUG][retry_zip] Retry ZIP for slug %s at %s", dash_slug, retry_zip_path) # Пере‑импортируем – **slug** передаётся, но клиент будет использовать ID self.to_c.import_dashboard( file_name=retry_zip_path, dash_id=dash_id, dash_slug=dash_slug, ) # type: ignore[attr-defined] self.logger.info("[INFO][execute_migration][RECOVERED] Dashboard slug '%s' re‑imported.", dash_slug) # ----------------------------------------------------------------- # 3️⃣ Финальная отчётность # ----------------------------------------------------------------- self.logger.info("[INFO][execute_migration] Migration finished.") msgbox("Информация", "Миграция завершена!") # [END_ENTITY] # [END_ENTITY: Service('Migration')] # -------------------------------------------------------------- # Точка входа # -------------------------------------------------------------- if __name__ == "__main__": Migration().run() # [END_FILE migration_script.py] # --------------------------------------------------------------