Работающий импорт с изменением БД

This commit is contained in:
Volobuev Andrey
2025-04-04 09:19:37 +03:00
parent 992073d2f5
commit 6ffc432b42
8 changed files with 491 additions and 266 deletions

2
.gitignore vendored
View File

@@ -4,4 +4,6 @@ dashboards/
*.ps1 *.ps1
*.ipynb *.ipynb
*.txt *.txt
*.zip
keyring passwords.py keyring passwords.py
Logs/

View File

@@ -6,22 +6,11 @@ import os
from pathlib import Path from pathlib import Path
from superset_tool.models import SupersetConfig, DatabaseConfig from superset_tool.models import SupersetConfig, DatabaseConfig
from superset_tool.client import SupersetClient from superset_tool.client import SupersetClient
from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.fileio import save_and_unpack_dashboard, archive_exports, sanitize_filename from superset_tool.utils.fileio import save_and_unpack_dashboard, archive_exports, sanitize_filename
# Настройка логирования
LOG_DIR = Path("P:\\Superset\\010 Бекапы\\Logs")
LOG_DIR.mkdir(exist_ok=True, parents=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(LOG_DIR / f"superset_backup_{datetime.now().strftime('%Y%m%d')}.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def setup_clients(): def setup_clients(logger: SupersetLogger):
"""Инициализация клиентов для разных окружений""" """Инициализация клиентов для разных окружений"""
clients = {} clients = {}
try: try:
@@ -34,11 +23,12 @@ def setup_clients():
"password": keyring.get_password("system", "dev migrate"), "password": keyring.get_password("system", "dev migrate"),
"refresh": True "refresh": True
}, },
logger=logger,
verify_ssl=False verify_ssl=False
) )
# Конфигурация для Prod # Конфигурация для Prod
sandbox_config = SupersetConfig( prod_config = SupersetConfig(
base_url="https://prodta.bi.dwh.rusal.com/api/v1", base_url="https://prodta.bi.dwh.rusal.com/api/v1",
auth={ auth={
"provider": "db", "provider": "db",
@@ -46,11 +36,12 @@ def setup_clients():
"password": keyring.get_password("system", "prod migrate"), "password": keyring.get_password("system", "prod migrate"),
"refresh": True "refresh": True
}, },
logger=logger,
verify_ssl=False verify_ssl=False
) )
# Конфигурация для Sandbox # Конфигурация для Sandbox
prod_config = SupersetConfig( sandbox_config = SupersetConfig(
base_url="https://sandboxta.bi.dwh.rusal.com/api/v1", base_url="https://sandboxta.bi.dwh.rusal.com/api/v1",
auth={ auth={
"provider": "db", "provider": "db",
@@ -58,20 +49,24 @@ def setup_clients():
"password": keyring.get_password("system", "sandbox migrate"), "password": keyring.get_password("system", "sandbox migrate"),
"refresh": True "refresh": True
}, },
logger=logger,
verify_ssl=False verify_ssl=False
) )
clients['dev'] = SupersetClient(dev_config) clients['dev'] = SupersetClient(dev_config)
clients['sbx'] = SupersetClient(sandbox_config) clients['sbx'] = SupersetClient(sandbox_config)
clients['prod'] = SupersetClient(prod_config)
logger.info("Клиенты для окружений успешно инициализированы") logger.info("Клиенты для окружений успешно инициализированы")
return clients return clients
except Exception as e: except Exception as e:
logger.error(f"Ошибка инициализации клиентов: {str(e)}") logger.error(f"Ошибка инициализации клиентов: {str(e)}")
raise raise
def backup_dashboards(client, env_name, backup_root): def backup_dashboards(client,
env_name,
backup_root,
logger: SupersetLogger ):
"""Выполнение бэкапа дашбордов для указанного окружения""" """Выполнение бэкапа дашбордов для указанного окружения"""
logger.info(f"Начало бэкапа для окружения {env_name}")
try: try:
dashboard_count, dashboard_meta = client.get_dashboards() dashboard_count, dashboard_meta = client.get_dashboards()
total = 0 total = 0
@@ -93,48 +88,57 @@ def backup_dashboards(client, env_name, backup_root):
unpack=False unpack=False
) )
logger.info(f"[{env_name}] Дашборд {dashboard_title} сохранен в {zip_path}")
success += 1 success += 1
#Очистка старых бэкапов #Очистка старых бэкапов
try: try:
archive_exports(dashboard_dir) archive_exports(dashboard_dir)
logger.debug(f"[{env_name}] Выполнена очистка для {dashboard_title}")
except Exception as cleanup_error: except Exception as cleanup_error:
logger.error(f"[{env_name}] Ошибка очистки {dashboard_title}: {str(cleanup_error)}") raise cleanup_error
except Exception as db_error: except Exception as db_error:
logger.error(f"[{env_name}] Ошибка обработки дашборда {dashboard_title}: {str(db_error)}", raise db_error
exc_info=True)
logger.info(f"Бэкап {env_name} завершен. Успешно: {success}/{total}. Всего на сервере - {dashboard_count}")
return success == total return success == total
except Exception as e: except Exception as e:
logger.error(f"Критическая ошибка при бэкапе {env_name}: {str(e)}", exc_info=True)
return False return False
def main(): def main():
# Инициализация логгера
log_dir = Path("P:\\Superset\\010 Бекапы\\Logs")
logger = SupersetLogger(
log_dir=log_dir,
level=logging.INFO,
console=True
)
"""Основная функция выполнения бэкапа""" """Основная функция выполнения бэкапа"""
logger.info("="*50) logger.info("="*50)
logger.info("Запуск процесса бэкапа Superset") logger.info("Запуск процесса бэкапа Superset")
logger.info("="*50) logger.info("="*50)
try: try:
clients = setup_clients() clients = setup_clients(logger)
superset_backup_repo = Path("P:\\Superset\\010 Бекапы") superset_backup_repo = Path("P:\\Superset\\010 Бекапы")
# Бэкап для DEV # Бэкап для DEV
dev_success = backup_dashboards( dev_success = backup_dashboards(
clients['dev'], clients['dev'],
"DEV", "DEV",
superset_backup_repo superset_backup_repo,
logger=logger
) )
#Бэкап для Sandbox #Бэкап для Sandbox
sbx_success = backup_dashboards( sbx_success = backup_dashboards(
clients['sbx'], clients['sbx'],
"SBX", "SBX",
superset_backup_repo superset_backup_repo,
logger=logger
)
#Бэкап для Прода
prod_success = backup_dashboards(
clients['prod'],
"PROD",
superset_backup_repo,
logger=logger
) )
# Итоговый отчет # Итоговый отчет
@@ -142,7 +146,8 @@ def main():
logger.info("Итоги выполнения бэкапа:") logger.info("Итоги выполнения бэкапа:")
logger.info(f"DEV: {'Успешно' if dev_success else 'С ошибками'}") logger.info(f"DEV: {'Успешно' if dev_success else 'С ошибками'}")
logger.info(f"SBX: {'Успешно' if sbx_success else 'С ошибками'}") logger.info(f"SBX: {'Успешно' if sbx_success else 'С ошибками'}")
logger.info(f"Полный лог доступен в: {LOG_DIR}") logger.info(f"PROD: {'Успешно' if prod_success else 'С ошибками'}")
logger.info(f"Полный лог доступен в: {log_dir}")
except Exception as e: except Exception as e:
logger.critical(f"Фатальная ошибка выполнения скрипта: {str(e)}", exc_info=True) logger.critical(f"Фатальная ошибка выполнения скрипта: {str(e)}", exc_info=True)

View File

@@ -1,31 +1,62 @@
from superset_tool.models import SupersetConfig from superset_tool.models import SupersetConfig
from superset_tool.client import SupersetClient from superset_tool.client import SupersetClient
from superset_tool.utils.logger import SupersetLogger
from superset_tool.exceptions import AuthenticationError from superset_tool.exceptions import AuthenticationError
from superset_tool.utils.fileio import save_and_unpack_dashboard, update_db_yaml, archive_exports, sync_for_git from superset_tool.utils.fileio import save_and_unpack_dashboard, update_db_yaml, create_dashboard_export
import os import os
import keyring import keyring
from pathlib import Path from pathlib import Path
import logging
log_dir = Path("H:\\dev\\Logs")
logger = SupersetLogger(
log_dir=log_dir,
level=logging.INFO,
console=True
)
database_config={"PROD": database_config_click={"new":
{ {
"database_name": "Prod Clickhouse", "database_name": "Prod Clickhouse",
"sqlalchemy_uri": "clickhousedb+connect://clicketl:XXXXXXXXXX@rgm-s-khclk.hq.root.ad:443/dm", "sqlalchemy_uri": "clickhousedb+connect://clicketl:XXXXXXXXXX@rgm-s-khclk.hq.root.ad:443/dm",
"uuid": "b9b67cb5-9874-4dc6-87bd-354fc33be6f9", "uuid": "b9b67cb5-9874-4dc6-87bd-354fc33be6f9",
"allow_ctas": "true",
"allow_cvas": "true",
"allow_dml": "true"
},
"DEV": {
"database_name": "Dev Clickhouse",
"sqlalchemy_uri": "clickhousedb+connect://dwhuser:XXXXXXXXXX@10.66.229.179:8123/dm",
"uuid": "b9b67cb5-9874-4dc6-87bd-354fc33be6f9",
"database_uuid": "b9b67cb5-9874-4dc6-87bd-354fc33be6f9", "database_uuid": "b9b67cb5-9874-4dc6-87bd-354fc33be6f9",
"allow_ctas": "true", "allow_ctas": "true",
"allow_cvas": "true", "allow_cvas": "true",
"allow_dml": "true" "allow_dml": "true"
},
"old": {
"database_name": "Dev Clickhouse",
"sqlalchemy_uri": "clickhousedb+connect://dwhuser:XXXXXXXXXX@10.66.229.179:8123/dm",
"uuid": "e9fd8feb-cb77-4e82-bc1d-44768b8d2fc2",
"database_uuid": "e9fd8feb-cb77-4e82-bc1d-44768b8d2fc2",
"allow_ctas": "true",
"allow_cvas": "true",
"allow_dml": "true"
} }
} }
database_config_gp={"new":
{
"database_name": "Prod Greenplum",
"sqlalchemy_uri": "postgresql+psycopg2://viz_powerbi_gp_prod:XXXXXXXXXX@10.66.229.201:5432/dwh",
"uuid": "805132a3-e942-40ce-99c7-bee8f82f8aa8",
"database_uuid": "805132a3-e942-40ce-99c7-bee8f82f8aa8",
"allow_ctas": "true",
"allow_cvas": "true",
"allow_dml": "true"
},
"old": {
"database_name": "DEV Greenplum",
"sqlalchemy_uri": "postgresql+psycopg2://viz_superset_gp_dev:XXXXXXXXXX@10.66.229.171:5432/dwh",
"uuid": "97b97481-43c3-4181-94c5-b69eaaa1e11f",
"database_uuid": "97b97481-43c3-4181-94c5-b69eaaa1e11f",
"allow_ctas": "false",
"allow_cvas": "false",
"allow_dml": "false"
}
}
# Конфигурация для Dev # Конфигурация для Dev
dev_config = SupersetConfig( dev_config = SupersetConfig(
base_url="https://devta.bi.dwh.rusal.com/api/v1", base_url="https://devta.bi.dwh.rusal.com/api/v1",
@@ -35,6 +66,7 @@ dev_config = SupersetConfig(
"password": keyring.get_password("system", "dev migrate"), "password": keyring.get_password("system", "dev migrate"),
"refresh": True "refresh": True
}, },
logger=logger,
verify_ssl=False verify_ssl=False
) )
@@ -47,6 +79,7 @@ prod_config = SupersetConfig(
"password": keyring.get_password("system", "prod migrate"), "password": keyring.get_password("system", "prod migrate"),
"refresh": True "refresh": True
}, },
logger=logger,
verify_ssl=False verify_ssl=False
) )
@@ -59,41 +92,45 @@ sandbox_config = SupersetConfig(
"password": keyring.get_password("system", "sandbox migrate"), "password": keyring.get_password("system", "sandbox migrate"),
"refresh": True "refresh": True
}, },
logger=logger,
verify_ssl=False verify_ssl=False
) )
# Инициализация клиента # Инициализация клиента
dev_client = SupersetClient(dev_config) dev_client = SupersetClient(dev_config)
#prod_client = SupersetClient(prod_config) sandbox_client = SupersetClient(sandbox_config)
prod_client = SupersetClient(prod_config)
dashboard_slug = "IM0010" from_c = dev_client
to_c = sandbox_client
dashboard_slug = "FI0050"
#dashboard_id = 53 #dashboard_id = 53
dashboard_meta = dev_client.get_dashboard(dashboard_slug) dashboard_meta = from_c.get_dashboard(dashboard_slug)
print(dashboard_meta) #print(dashboard_meta)
# print(dashboard_meta["dashboard_title"]) print(dashboard_meta["dashboard_title"])
#dashboard_id = dashboard_meta["id"] dashboard_id = dashboard_meta["id"]
# zip_content, filename = prod_client.export_dashboard(dashboard_id) zip_content, filename = from_c.export_dashboard(dashboard_id, logger=logger)
# superset_repo = "H:\\Superset\\repo\\" superset_repo = Path("H:\\dev\\dashboards\\")
# # print(f"Экспортируем дашборд ID = {dashboard_id}...") # print(f"Экспортируем дашборд ID = {dashboard_id}...")
# # #Сохранение и распаковка # #Сохранение и распаковка
# zip_path, unpacked_path = save_and_unpack_dashboard( zip_path, unpacked_path = save_and_unpack_dashboard(
# zip_content=zip_content, zip_content=zip_content,
# original_filename=filename, original_filename=filename,
# output_dir=f"dashboards\{dashboard_slug}" unpack=True,
# ) logger=logger,
# dest_path = os.path.join(superset_repo,dashboard_slug) output_dir=os.path.join(superset_repo,dashboard_slug)
# source_path = os.path.join(unpacked_path,Path(filename).stem) )
# print(dest_path) dest_path = os.path.join(superset_repo,dashboard_slug)
# print(source_path) source_path = os.path.join(unpacked_path,Path(filename).stem)
# sync_for_git(source_path=source_path,destination_path=dest_path,verbose=True)
# print(f"Сохранено в: {zip_path}") update_db_yaml(database_config_click, path = source_path, logger=logger)
# print(f"Распаковано в: {unpacked_path}") update_db_yaml(database_config_gp, path = source_path, logger=logger)
# update_db_yaml(prod_config.database_config, path = unpacked_path,verbose=False)
# prod_client.import_dashboard(zip_path) create_dashboard_export(f"{dashboard_slug}.zip",[source_path],logger=logger)
zip_path = Path(f"{dashboard_slug}.zip")
to_c.import_dashboard(zip_path)
# archive_exports("dashboards", max_files=3)

View File

@@ -7,11 +7,13 @@ from pydantic import BaseModel, Field
from .utils.fileio import * from .utils.fileio import *
from .exceptions import * from .exceptions import *
from .models import SupersetConfig from .models import SupersetConfig
from .utils.logger import SupersetLogger
class SupersetClient: class SupersetClient:
def __init__(self, config: SupersetConfig): def __init__(self, config: SupersetConfig):
self.config = config self.config = config
self.logger = config.logger or SupersetLogger(console=False)
self.session = requests.Session() self.session = requests.Session()
self._setup_session() self._setup_session()
self._authenticate() self._authenticate()
@@ -25,6 +27,7 @@ class SupersetClient:
if not self.config.verify_ssl: if not self.config.verify_ssl:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self.logger.debug(f"Проверка сертификатов SSL отключена")
self.session.mount('https://', adapter) self.session.mount('https://', adapter)
self.session.verify = self.config.verify_ssl self.session.verify = self.config.verify_ssl
@@ -45,20 +48,25 @@ class SupersetClient:
) )
response.raise_for_status() response.raise_for_status()
self.access_token = response.json()["access_token"] self.access_token = response.json()["access_token"]
self.logger.info(f"Токен Bearer {self.access_token} получен c {login_url}")
# Затем получаем CSRF токен с использованием access_token # Затем получаем CSRF токен с использованием access_token
csrf_url = f"{self.config.base_url}/security/csrf_token/" csrf_url = f"{self.config.base_url}/security/csrf_token/"
response = self.session.get( response = self.session.get(
csrf_url, csrf_url,
headers={"Authorization": f"Bearer {self.access_token}"}, headers={"Authorization": f"Bearer {self.access_token}"},
verify=self.config.verify_ssl verify=self.config.verify_ssl
) )
response.raise_for_status() response.raise_for_status()
self.csrf_token = response.json()["result"] self.csrf_token = response.json()["result"]
self.logger.info(f"Токен CSRF {self.csrf_token} получен c {csrf_url}")
except HTTPError as e: except HTTPError as e:
if e.response.status_code == 401: if e.response.status_code == 401:
error_msg = "Invalid credentials" if "login" in e.request.url else "CSRF token fetch failed" error_msg = f"Неверные данные для аутенфикации для {login_url}" if "login" in e.request.url else f"Не удалось получить CSRF токен с {csrf_url}"
raise AuthenticationError(f"{error_msg}. Check auth configuration") from e self.logger.error(f"Ошибка получения: {error_msg}")
raise AuthenticationError(f"{error_msg}. Проверь данные аутенфикации") from e
raise raise
@@ -78,7 +86,7 @@ class SupersetClient:
:dashboard_id_or_slug - id или короткая ссылка :dashboard_id_or_slug - id или короткая ссылка
""" """
url = f"{self.config.base_url}/dashboard/{dashboard_id_or_slug}" url = f"{self.config.base_url}/dashboard/{dashboard_id_or_slug}"
self.logger.debug(f"Получаем информацию по дашборду с /{url}...")
try: try:
response = self.session.get( response = self.session.get(
url, url,
@@ -86,9 +94,11 @@ class SupersetClient:
timeout=self.config.timeout timeout=self.config.timeout
) )
response.raise_for_status() response.raise_for_status()
self.logger.info(f"ОК - Получили информацию по дашборду с {url}")
return response.json()["result"] return response.json()["result"]
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
raise SupersetAPIError(f"Failed to get dashboard: {str(e)}") from e self.logger.error(f"Ошибка при получении информации о дашборде: {str(e)}", exc_info=True)
raise SupersetAPIError(f"Ошибка при получении информации о дашборде: {str(e)}") from e
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]: def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
@@ -102,6 +112,7 @@ class SupersetClient:
Tuple[int, List[Dict]]: Кортеж, содержащий общее количество дашбордов и список всех дашбордов. Tuple[int, List[Dict]]: Кортеж, содержащий общее количество дашбордов и список всех дашбордов.
""" """
url = f"{self.config.base_url}/dashboard/" url = f"{self.config.base_url}/dashboard/"
self.logger.debug(f"Получаем информацию по дашбордам с {url}...")
modified_query: Dict = {} modified_query: Dict = {}
all_results: List[Dict] = [] all_results: List[Dict] = []
total_count: int = 0 total_count: int = 0
@@ -114,7 +125,9 @@ class SupersetClient:
headers=self.headers, headers=self.headers,
timeout=self.config.timeout timeout=self.config.timeout
).json()['count'] ).json()['count']
self.logger.info(f"ОК - Получили кол-во дашбордов ({total_count}) с {url}")
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
self.logger.error(f"Ошибка при получении кол-ва дашбордов: {str(e)}", exc_info=True)
raise SupersetAPIError(f"Ошибка при получении кол-ва дашбордов: {str(e)}") from e raise SupersetAPIError(f"Ошибка при получении кол-ва дашбордов: {str(e)}") from e
#Инициализация параметров запроса с учетом переданного query #Инициализация параметров запроса с учетом переданного query
@@ -151,18 +164,19 @@ class SupersetClient:
params={"q": json.dumps(modified_query)} , params={"q": json.dumps(modified_query)} ,
timeout=self.config.timeout timeout=self.config.timeout
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
all_results.extend(data.get("result", [])) all_results.extend(data.get("result", []))
current_page += 1 current_page += 1
self.logger.info(f"ОК - Получили информацию по дашбордам с {url}")
# Проверка, достигли ли последней страницы # Проверка, достигли ли последней страницы
return total_count, all_results return total_count, all_results
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
raise SupersetAPIError(f"Ошибка при получении дашбордов: {str(e)}") from e self.logger.error(f"Ошибка при получении информации о дашбордах: {str(e)}", exc_info=True)
raise SupersetAPIError(f"Ошибка при получении информации о дашбордах: {str(e)}") from e
def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]: def export_dashboard(self, dashboard_id: int, logger: Optional[SupersetLogger] = None) -> Tuple[bytes, str]:
"""Экспортирует дашборд из Superset в виде ZIP-архива и возвращает его содержимое с именем файла. """Экспортирует дашборд из Superset в виде ZIP-архива и возвращает его содержимое с именем файла.
Параметры: Параметры:
@@ -193,6 +207,8 @@ class SupersetClient:
""" """
url = f"{self.config.base_url}/dashboard/export/" url = f"{self.config.base_url}/dashboard/export/"
params = {"q": f"[{dashboard_id}]"} params = {"q": f"[{dashboard_id}]"}
logger = logger or SupersetLogger(name="client", console=False)
self.logger.debug(f"Экспортируем дашборд ID {dashboard_id} c {url}...")
try: try:
response = self.session.get( response = self.session.get(
@@ -204,9 +220,11 @@ class SupersetClient:
response.raise_for_status() response.raise_for_status()
filename = get_filename_from_headers(response.headers) or f"dashboard_{dashboard_id}.zip" filename = get_filename_from_headers(response.headers) or f"dashboard_{dashboard_id}.zip"
self.logger.info(f"Дашборд сохранен в {filename}")
return response.content, filename return response.content, filename
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
self.logger.error(f"Ошибка при экспорте: {str(e)}", exc_info=True)
raise SupersetAPIError(f"Export failed: {str(e)}") from e raise SupersetAPIError(f"Export failed: {str(e)}") from e
@@ -241,7 +259,7 @@ class SupersetClient:
- При конфликте имен может потребоваться ручное разрешение через параметры импорта - При конфликте имен может потребоваться ручное разрешение через параметры импорта
""" """
url = f"{self.config.base_url}/dashboard/import/" url = f"{self.config.base_url}/dashboard/import/"
self.logger.debug(f"Импортируем дашборд ID {zip_path} на {url}...")
headers_without_content_type = {k: v for k, v in self.headers.items() if k.lower() != 'content-type'} headers_without_content_type = {k: v for k, v in self.headers.items() if k.lower() != 'content-type'}
zip_name = zip_path.name zip_name = zip_path.name
@@ -266,8 +284,10 @@ class SupersetClient:
# Обрабатываем ответ # Обрабатываем ответ
try: try:
response.raise_for_status() response.raise_for_status()
self.logger.info(f"Дашборд импортирован успешно")
return response.json() return response.json()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.error(f"Ошибка при импорте: {str(e)}", exc_info=True)
error_detail = f"{e.response.status_code} {e.response.reason}" error_detail = f"{e.response.status_code} {e.response.reason}"
if e.response.text: if e.response.text:
error_detail += f"\nТело ответа: {e.response.text}" error_detail += f"\nТело ответа: {e.response.text}"

View File

@@ -1,10 +1,21 @@
from pydantic import BaseModel # models.py
from pydantic import BaseModel, validator
from typing import Optional
from .utils.logger import SupersetLogger
class SupersetConfig(BaseModel): class SupersetConfig(BaseModel):
base_url: str base_url: str
auth: dict # Словарь с параметрами аутентификации auth: dict
verify_ssl: bool = True verify_ssl: bool = True
timeout: int = 30 timeout: int = 30
logger: Optional[SupersetLogger] = None
class Config:
arbitrary_types_allowed = True # Разрешаем произвольные типы
class DatabaseConfig(BaseModel): class DatabaseConfig(BaseModel):
database_config: dict database_config: dict
logger: Optional[SupersetLogger] = None
class Config:
arbitrary_types_allowed = True

View File

@@ -1,66 +1,147 @@
import re import re
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple, Dict from typing import Optional, Tuple, Dict, Any
import datetime import datetime
import shutil import shutil
import yaml import yaml
import tempfile import tempfile
import os import os
from contextlib import contextmanager from contextlib import contextmanager
from ..utils.logger import SupersetLogger
@contextmanager @contextmanager
def create_temp_file(content: bytes, suffix: str = ".zip"): def create_temp_file(
content: bytes,
suffix: str = ".zip",
logger: Optional[SupersetLogger] = None
):
"""Контекстный менеджер для создания временных файлов с логированием"""
logger = logger or SupersetLogger(name="fileio", console=False)
try: try:
logger.debug(f"Создание временного файла с суффиксом {suffix}")
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(content) tmp.write(content)
tmp.flush() tmp.flush()
yield Path(tmp.name) yield Path(tmp.name)
except Exception as e:
logger.error(
f"Ошибка создания временного файла: {str(e)}", exc_info=True)
raise
finally: finally:
if Path(tmp.name).exists(): if Path(tmp.name).exists():
Path(tmp.name).unlink() Path(tmp.name).unlink()
logger.debug(f"Временный файл {tmp.name} удален")
def save_and_unpack_dashboard( def save_and_unpack_dashboard(
zip_content: bytes, zip_content: bytes,
output_dir: str = "dashboards", output_dir: str = "dashboards",
unpack: bool = False, unpack: bool = False,
original_filename: Optional[str] = None original_filename: Optional[str] = None,
) -> Tuple[Path, Path]: logger: Optional[SupersetLogger] = None
"""Сохраняет и распаковывает дашборд с учетом оригинального имени""" ) -> Tuple[Path, Optional[Path]]:
"""Сохраняет и распаковывает дашборд с логированием"""
logger = logger or SupersetLogger(name="fileio", console=False)
logger.info(f"Старт обработки дашборда. Распаковка: {unpack}")
try: try:
output_path = Path(output_dir) output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True) output_path.mkdir(parents=True, exist_ok=True)
logger.debug(f"Директория {output_path} создана/проверена")
# Генерируем имя файла zip_name = sanitize_filename(
if original_filename: original_filename) if original_filename else None
zip_name = sanitize_filename(original_filename) if not zip_name:
else:
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
zip_name = f"dashboard_export_{timestamp}.zip" zip_name = f"dashboard_export_{timestamp}.zip"
logger.debug(f"Сгенерировано имя файла: {zip_name}")
zip_path = output_path / zip_name zip_path = output_path / zip_name
logger.info(f"Сохранение дашборда в: {zip_path}")
# Сохраняем ZIP-файл
with open(zip_path, "wb") as f: with open(zip_path, "wb") as f:
f.write(zip_content) f.write(zip_content)
logger.info(f"Дашборд успешно сохранен: {zip_path}")
if unpack: if unpack:
# Распаковываем в поддиректорию с именем архива
#extract_dir_name = zip_path.stem
extract_path = output_path extract_path = output_path
logger.debug(f"Начало распаковки в: {extract_path}")
with zipfile.ZipFile(zip_path, 'r') as zip_ref: with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(extract_path) zip_ref.extractall(extract_path)
logger.info(f"Дашборд распакован в: {extract_path}")
return zip_path, extract_path return zip_path, extract_path
return zip_path return zip_path, None
except Exception as e: except Exception as e:
logger.error(f"Ошибка обработки дашборда: {str(e)}", exc_info=True)
raise RuntimeError(f"Failed to unpack dashboard: {str(e)}") from e raise RuntimeError(f"Failed to unpack dashboard: {str(e)}") from e
def create_dashboard_export(zip_name, source_paths,
exclude_extensions=None,
compress_type=zipfile.ZIP_DEFLATED,
logger: Optional[SupersetLogger] = None):
"""
Создает ZIP-архив с сохранением оригинальной структуры директорий
Параметры:
zip_name (str): Имя создаваемого ZIP-архива
source_paths (list): Список путей для добавления в архив
exclude_extensions (list): Расширения файлов для исключения (например, ['.tmp', '.log'])
compress_type: Тип сжатия (по умолчанию ZIP_DEFLATED)
"""
logger = logger or SupersetLogger(name="fileio", console=False)
logger.info(f"Упаковываем дашборд {source_paths} в {zip_name}")
try:
exclude_ext = [ext.lower() for ext in exclude_extensions] if exclude_extensions else []
# Проверка существования исходных путей
for path in source_paths:
if not os.path.exists(path):
raise FileNotFoundError(f"Путь не найден: {path}")
# Сбор всех файлов с их базовыми путями
files_with_base = []
for path in source_paths:
abs_path = os.path.abspath(path)
if os.path.isfile(abs_path):
# Для файла: базовый путь = директория файла
base_path = os.path.dirname(abs_path)
files_with_base.append((abs_path, base_path))
elif os.path.isdir(abs_path):
# Для директории: базовый путь = сама директория
base_path = abs_path
for root, _, files in os.walk(abs_path):
for file in files:
full_path = os.path.join(root, file)
files_with_base.append((full_path, base_path))
# Фильтрация по расширениям
if exclude_ext:
files_with_base = [
(f, b) for f, b in files_with_base
if os.path.splitext(f)[1].lower() not in exclude_ext
]
# Создание архива
with zipfile.ZipFile(zip_name, 'w', compress_type) as zipf:
for file, base_path in files_with_base:
# Вычисляем относительный путь от base_path
rel_path = os.path.relpath(file, start=base_path)
arcname = os.path.join(Path(zip_name).stem, rel_path)
zipf.write(file, arcname=arcname)
logger.debug(f"Добавлен {arcname}")
logger.info(f"Архив создан {zip_name}")
return True
except Exception as e:
logger.error(f"\nОшибка: {str(e)}")
return False
def get_filename_from_headers(headers: dict) -> Optional[str]: def get_filename_from_headers(headers: dict) -> Optional[str]:
"""Извлекает имя файла из заголовков HTTP-ответа""" """Извлекает имя файла из заголовков HTTP-ответа"""
@@ -76,18 +157,17 @@ def get_filename_from_headers(headers: dict) -> Optional[str]:
return filename_match[0].strip('"') return filename_match[0].strip('"')
return None return None
def sanitize_filename(filename: str) -> str: def sanitize_filename(filename: str) -> str:
"""Очищает имя файла от потенциально опасных символов""" """Очищает имя файла от потенциально опасных символов"""
return re.sub(r'[\\/*?:"<>|]', "_", filename).strip() return re.sub(r'[\\/*?:"<>|]', "_", filename).strip()
def archive_exports( def archive_exports(
output_dir: str, output_dir: str,
daily_retention: int = 7, daily_retention: int = 7,
weekly_retention: int = 4, weekly_retention: int = 4,
monthly_retention: int = 12, monthly_retention: int = 12,
yearly_retention: Optional[int] = None yearly_retention: Optional[int] = None,
logger: Optional[SupersetLogger] = None
): ):
"""Управление историей экспортов по политике GFS (Grandfather-Father-Son) """Управление историей экспортов по политике GFS (Grandfather-Father-Son)
Параметры: Параметры:
@@ -98,6 +178,8 @@ def archive_exports(
Извлекает даты из стандартного суперсетовсого архива вида, либо берет дату изменения архива Извлекает даты из стандартного суперсетовсого архива вида, либо берет дату изменения архива
dashboard_export_20250326T121517.zip""" dashboard_export_20250326T121517.zip"""
logger = logger or SupersetLogger(name="fileio", console=False)
logger.info(f"Старт очистки архивов в {output_dir}")
export_dir = Path(output_dir) export_dir = Path(output_dir)
files_with_dates = [] files_with_dates = []
@@ -113,9 +195,10 @@ def archive_exports(
# Если не удалось распарсить - используем дату изменения файла # Если не удалось распарсить - используем дату изменения файла
mtime = file.stat().st_mtime mtime = file.stat().st_mtime
date = datetime.datetime.fromtimestamp(mtime).date() date = datetime.datetime.fromtimestamp(mtime).date()
logger.warning(f"Использована дата модификации для {file.name}")
files_with_dates.append((file, date)) files_with_dates.append((file, date))
try:
# Сортируем файлы по дате (новые сначала) # Сортируем файлы по дате (новые сначала)
files_with_dates.sort(key=lambda x: x[1], reverse=True) files_with_dates.sort(key=lambda x: x[1], reverse=True)
@@ -143,20 +226,24 @@ def archive_exports(
keep_files = set() keep_files = set()
# Daily - последние N дней # Daily - последние N дней
sorted_daily = sorted(daily_groups.keys(), reverse=True)[:daily_retention] sorted_daily = sorted(daily_groups.keys(), reverse=True)[
:daily_retention]
keep_files.update(daily_groups[d] for d in sorted_daily) keep_files.update(daily_groups[d] for d in sorted_daily)
# Weekly - последние N недель # Weekly - последние N недель
sorted_weekly = sorted(weekly_groups.keys(), reverse=True)[:weekly_retention] sorted_weekly = sorted(weekly_groups.keys(), reverse=True)[
:weekly_retention]
keep_files.update(weekly_groups[w] for w in sorted_weekly) keep_files.update(weekly_groups[w] for w in sorted_weekly)
# Monthly - последние N месяцев # Monthly - последние N месяцев
sorted_monthly = sorted(monthly_groups.keys(), reverse=True)[:monthly_retention] sorted_monthly = sorted(monthly_groups.keys(), reverse=True)[
:monthly_retention]
keep_files.update(monthly_groups[m] for m in sorted_monthly) keep_files.update(monthly_groups[m] for m in sorted_monthly)
# Yearly - все или последние N лет # Yearly - все или последние N лет
if yearly_retention is not None: if yearly_retention is not None:
sorted_yearly = sorted(yearly_groups.keys(), reverse=True)[:yearly_retention] sorted_yearly = sorted(yearly_groups.keys(), reverse=True)[
:yearly_retention]
keep_files.update(yearly_groups[y] for y in sorted_yearly) keep_files.update(yearly_groups[y] for y in sorted_yearly)
else: else:
keep_files.update(yearly_groups.values()) keep_files.update(yearly_groups.values())
@@ -165,10 +252,14 @@ def archive_exports(
for file, _ in files_with_dates: for file, _ in files_with_dates:
if file not in keep_files: if file not in keep_files:
file.unlink() file.unlink()
# unpacked_dir = export_dir / file.stem unpacked_dir = export_dir / file.stem
# if unpacked_dir.exists(): if unpacked_dir.exists():
# shutil.rmtree(unpacked_dir) shutil.rmtree(unpacked_dir)
logger.info(f"Очистка завершена. Сохранено {len(keep_files)} файлов")
except Exception as e:
logger.error(f"Ошибка очистки архивов: {str(e)}", exc_info=True)
raise
def determine_and_load_yaml_type(file_path): def determine_and_load_yaml_type(file_path):
with open(file_path, 'r') as f: with open(file_path, 'r') as f:
@@ -185,67 +276,70 @@ def determine_and_load_yaml_type(file_path):
else: else:
return data, 'unknown' return data, 'unknown'
def update_db_yaml(
def update_db_yaml(db_config: Dict = None, path: str = "dashboards", verbose: bool = False) -> None: db_config: Dict = None,
path: str = "dashboards",
logger: Optional[SupersetLogger] = None
) -> None:
""" """
Обновляет конфигурации в YAML-файлах баз данных согласно переданному словарю замен Обновляет конфигурации в YAML-файлах баз данных, заменяя старые значения на новые
:param db_config: Словарь с параметрами для замены (ключ: значение) :param db_config: Словарь с параметрами для замены в формате:
:param path: Путь к папке с YAML-файлами (по умолчанию 'databases') {
"old": {старые_ключи: значения_для_поиска},
"new": {новые_ключи: значения_для_замены}
}
:param path: Путь к папке с YAML-файлами
""" """
# Устанавливаем дефолтные значения logger = logger or SupersetLogger(name="fileio", console=False)
logger.info("Старт обновления YAML-конфигов")
try:
db_config = db_config or {} db_config = db_config or {}
old_config = db_config.get("old", {}) # Значения для поиска
new_config = db_config.get("new", {}) # Значения для замены
# Ищем все YAML-файлы в указанной папке
databases_dir = Path(path) databases_dir = Path(path)
yaml_files = databases_dir.rglob("*.yaml") yaml_files = databases_dir.rglob("*.yaml")
for file_path in yaml_files: for file_path in yaml_files:
try: try:
# Чтение и загрузка YAML result = determine_and_load_yaml_type(file_path)
data, yaml_type = determine_and_load_yaml_type(file_path) or {} data, yaml_type = result if result else ({}, None)
logger.debug(f"Тип {file_path} - {yaml_type}")
# Обновляем только существующие ключи updates = {}
updates = { for key in old_config:
k: v if key in data and data[key] == old_config[key]:
for k, v in db_config.items() new_value = new_config.get(key)
if ( if new_value is not None and new_value != data.get(key):
k in data # ключ есть в data updates[key] = new_value
and data[k] != v # значение отличается
and (
# для database — все ключи
(yaml_type == "database") or
# для dataset — исключаем uuid
(yaml_type == "dataset" and k != "uuid")
)
)
}
# Обновляем data if updates:
logger.info(f"Обновление {file_path}: {updates}")
data.update(updates) data.update(updates)
if verbose and updates:
print(f"Обработан {file_path}")
print(updates)
# Сохранение с сохранением структуры файла
with open(file_path, 'w') as file: with open(file_path, 'w') as file:
yaml.dump( yaml.dump(
data, data,
file, file,
default_flow_style=False, default_flow_style=False,
sort_keys=False, sort_keys=False
allow_unicode=True
) )
except Exception as e: except Exception as e:
print(f"Ошибка при обработке файла {file_path}: {str(e)}") logger.error(f"Ошибка обработки {file_path}: {str(e)}", exc_info=True)
except Exception as e:
logger.error(f"Критическая ошибка обновления: {str(e)}", exc_info=True)
raise
def sync_for_git( def sync_for_git(
source_path: str, source_path: str,
destination_path: str, destination_path: str,
dry_run: bool = False, dry_run: bool = False,
verbose: bool = False logger: Optional[SupersetLogger] = None
) -> None: ) -> None:
""" """
Синхронизирует содержимое папки source_path с destination_path. Синхронизирует содержимое папки source_path с destination_path.
@@ -259,7 +353,10 @@ def sync_for_git(
:param dry_run: Режим имитации (не вносит реальных изменений) :param dry_run: Режим имитации (не вносит реальных изменений)
:param verbose: Подробный вывод операций :param verbose: Подробный вывод операций
""" """
logger = logger or SupersetLogger(name="fileio", console=False)
logger.info("Старт перезаписи целевой папки")
source_files = set() source_files = set()
for root, _, files in os.walk(source_path): for root, _, files in os.walk(source_path):
for file in files: for file in files:
rel_path = os.path.relpath(os.path.join(root, file), source_path) rel_path = os.path.relpath(os.path.join(root, file), source_path)
@@ -268,7 +365,8 @@ def sync_for_git(
destination_files = set() destination_files = set()
for root, _, files in os.walk(destination_path): for root, _, files in os.walk(destination_path):
for file in files: for file in files:
rel_path = os.path.relpath(os.path.join(root, file), destination_path) rel_path = os.path.relpath(
os.path.join(root, file), destination_path)
destination_files.add(rel_path) destination_files.add(rel_path)
# Копирование/обновление файлов # Копирование/обновление файлов
@@ -277,19 +375,10 @@ def sync_for_git(
dst = os.path.join(destination_path, file) dst = os.path.join(destination_path, file)
dest_dir = os.path.dirname(dst) dest_dir = os.path.dirname(dst)
if verbose:
status = "[DRY-RUN] " if dry_run else ""
print(f"{status}Creating directory: {dest_dir}")
if not dry_run:
os.makedirs(dest_dir, exist_ok=True) os.makedirs(dest_dir, exist_ok=True)
if verbose:
status = "[DRY-RUN] " if dry_run else ""
print(f"{status}Copying: {file}")
if not dry_run:
shutil.copy2(src, dst) shutil.copy2(src, dst)
logger.info(f"Копируем: {file}")
# Удаление лишних файлов # Удаление лишних файлов
files_to_delete = destination_files - source_files files_to_delete = destination_files - source_files
@@ -301,28 +390,20 @@ def sync_for_git(
# Пропускаем .git и его содержимое # Пропускаем .git и его содержимое
try: try:
if git_dir in target.parents or target == git_dir: if git_dir in target.parents or target == git_dir:
if verbose: logger.info(f"Пропускаем .git: {target}")
print(f"Skipping .git item: {target}")
continue continue
except ValueError: except ValueError:
pass pass
if verbose:
action = "Would delete" if dry_run else "Deleting"
print(f"{action}: {target}")
if not dry_run:
try: try:
if target.is_file(): if target.is_file():
target.unlink() target.unlink()
elif target.is_dir(): elif target.is_dir():
shutil.rmtree(target) shutil.rmtree(target)
except OSError as e: except OSError as e:
print(f"Error deleting {target}: {e}") logger.error(f"Ошибка удаления: {target}: {e}")
# Удаление пустых директорий # Удаление пустых директорий
if verbose:
print("\nChecking for empty directories...")
git_dir = Path(destination_path) / ".git" git_dir = Path(destination_path) / ".git"
deleted_dirs = set() deleted_dirs = set()
@@ -341,15 +422,11 @@ def sync_for_git(
# Проверяем что директория пуста и не была удалена ранее # Проверяем что директория пуста и не была удалена ранее
if dir_path not in deleted_dirs and not any(dir_path.iterdir()): if dir_path not in deleted_dirs and not any(dir_path.iterdir()):
if verbose:
status = "[DRY-RUN] " if dry_run else ""
print(f"{status}Deleting empty directory: {dir_path}")
if not dry_run:
try: try:
dir_path.rmdir() dir_path.rmdir()
deleted_dirs.add(dir_path) deleted_dirs.add(dir_path)
logger.info(f"Удаляем пустую директорию: {dir_path}")
except OSError as e: except OSError as e:
print(f"Error deleting directory {dir_path}: {e}") logger.error(f"Ошибка удаления: {dir_path}: {e}")

View File

@@ -1,5 +1,61 @@
# utils/logger.py # utils/logger.py
def configure_logger(): import logging
logger = logging.getLogger("superset_migration") from datetime import datetime
# Настройка формата, обработчиков и уровня логирования from pathlib import Path
return logger from typing import Optional
class SupersetLogger:
def __init__(
self,
name: str = "superset_tool",
log_dir: Optional[Path] = None,
level: int = logging.INFO,
console: bool = True
):
self.logger = logging.getLogger(name)
self.logger.setLevel(level)
formatter = logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s'
)
# Очищаем существующие обработчики
if self.logger.handlers:
for handler in self.logger.handlers[:]:
self.logger.removeHandler(handler)
# Файловый обработчик
if log_dir:
log_dir.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(
log_dir / f"{name}_{self._get_timestamp()}.log"
)
file_handler.setFormatter(formatter)
self.logger.addHandler(file_handler)
# Консольный обработчик
if console:
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler)
def _get_timestamp(self) -> str:
return datetime.now().strftime("%Y%m%d")
def info(self, message: str, exc_info: bool = False):
self.logger.info(message, exc_info=exc_info)
def error(self, message: str, exc_info: bool = False):
self.logger.error(message, exc_info=exc_info)
def warning(self, message: str, exc_info: bool = False):
self.logger.warning(message, exc_info=exc_info)
def debug(self, message: str, exc_info: bool = False):
self.logger.debug(message, exc_info=exc_info)
def exception(self, message: str):
self.logger.exception(message)
def critical(self, message: str, exc_info: bool = False):
self.logger.critical(message, exc_info=exc_info)

View File

@@ -25,17 +25,6 @@ def setup_clients():
"""Инициализация клиентов для разных окружений""" """Инициализация клиентов для разных окружений"""
clients = {} clients = {}
try: try:
# Конфигурация для Dev
dev_config = SupersetConfig(
base_url="https://devta.bi.dwh.rusal.com/api/v1",
auth={
"provider": "db",
"username": "migrate_user",
"password": keyring.get_password("system", "dev migrate"),
"refresh": True
},
verify_ssl=False
)
# Конфигурация для Prod # Конфигурация для Prod
prod_config = SupersetConfig( prod_config = SupersetConfig(
@@ -48,6 +37,18 @@ def setup_clients():
}, },
verify_ssl=False verify_ssl=False
) )
# Конфигурация для Dev
dev_config = SupersetConfig(
base_url="https://devta.bi.dwh.rusal.com/api/v1",
auth={
"provider": "db",
"username": "migrate_user",
"password": keyring.get_password("system", "dev migrate"),
"refresh": True
},
verify_ssl=False
)
# Конфигурация для Sandbox # Конфигурация для Sandbox
sandbox_config = SupersetConfig( sandbox_config = SupersetConfig(
@@ -60,10 +61,19 @@ def setup_clients():
}, },
verify_ssl=False verify_ssl=False
) )
try:
clients['dev'] = SupersetClient(dev_config) clients['dev'] = SupersetClient(dev_config)
except Exception as e:
logger.error(f"Ошибка инициализации клиента: {str(e)}")
try:
clients['sbx'] = SupersetClient(sandbox_config) clients['sbx'] = SupersetClient(sandbox_config)
logger.info("Клиенты для окружений успешно инициализированы") except Exception as e:
logger.error(f"Ошибка инициализации клиента: {str(e)}")
try:
clients['prod'] = SupersetClient(prod_config)
except Exception as e:
logger.error(f"Ошибка инициализации клиента: {str(e)}")
return clients return clients
except Exception as e: except Exception as e:
logger.error(f"Ошибка инициализации клиентов: {str(e)}") logger.error(f"Ошибка инициализации клиентов: {str(e)}")
@@ -71,9 +81,9 @@ def setup_clients():
def backup_dashboards(client, env_name, backup_root): def backup_dashboards(client, env_name, backup_root):
"""Выполнение бэкапа дашбордов для указанного окружения""" """Выполнение бэкапа дашбордов для указанного окружения"""
logger.info(f"Начало бэкапа для окружения {env_name}") #logger.info(f"Начало бэкапа для окружения {env_name}")
print(client.get_dashboards()) #print(client.get_dashboards())
# dashboard_count,dashboard_meta = client.get_dashboards() # dashboard_count,dashboard_meta = client.get_dashboards()
# total = 0 # total = 0
# success = 0 # success = 0
@@ -100,9 +110,16 @@ dev_success = backup_dashboards(
superset_backup_repo superset_backup_repo
) )
# Бэкап для Sandbox # Бэкап для Sandbox
# sbx_success = backup_dashboards( sbx_success = backup_dashboards(
# clients['sbx'], clients['sbx'],
# "SBX", "SBX",
# superset_backup_repo superset_backup_repo
# ) )
prod_success = backup_dashboards(
clients['prod'],
"PROD",
superset_backup_repo
)