Files
ss-tools/migration_script.py
Volobuev Andrey 8f6b44c679 backup worked
2025-10-06 13:59:30 +03:00

442 lines
22 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# [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] Deleteonfailure = %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] YAMLfiles 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] Repacked 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' reimported.", 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]
# --------------------------------------------------------------