442 lines
22 KiB
Python
442 lines
22 KiB
Python
# [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]
|
||
# -------------------------------------------------------------- |