# [DEF:Doc.Adr.ADR0019:ADR] # @STATUS ACCEPTED # @BRIEF Импорт дашбордов Superset: механизм трансформации UUID БД, cross-filter patching, password injection, и разделение dry-run/execute. # @RELATION BINDS_TO -> [Plugin.Migration.MigrationPlugin] # @RELATION BINDS_TO -> [Core.MigrationEngine] # @RELATION DEPENDS_ON -> [Doc.Adr.ADR0003:ADR] # @RELATION DEPENDS_ON -> [Doc.Adr.ADR0016:ADR] # @RATIONALE Механизм миграции дашбордов Superset между окружениями — ключевой компонент системы. Принятые решения о трансформации ZIP-архивов, обработке паролей БД, cross-filter patching, и разделении dry-run/execute сформировались через несколько итераций отладки production-сбоев. Этот ADR фиксирует финальную архитектуру и отклонённые альтернативы. # @REJECTED strip_databases=True — вызывает каскадный сбой (пустой database_ids → пропуск всех datasets → пропуск всех charts → update_id_refs KeyError → error 1010). # @REJECTED Передача add_log_callback из плагина в TaskManager.await_input() — менеджер сам управляет логированием через self._add_log; внешняя передача ломает сигнатуру метода. # @REJECTED Создание MigrationEngine без mapping_service в dry-run — лишает cross-filter patching возможности работать. ## Контекст `superset-tools` мигрирует дашборды между окружениями Superset (dev → preprod → prod). Процесс: экспорт дашборда как ZIP-архива из источника → трансформация содержимого (ZIP-архив содержит YAML-файлы datasets, databases, dashboards) → импорт в целевой Superset. Ключевые вызовы: - **Безопасность:** дашборд не должен импортироваться с исходными (source) подключениями к БД — это привело бы к утечке данных между окружениями. - **Целостность:** имена файлов БД в архиве могут содержать точки (например, `Dev_Clickhouse_Node_1.yaml`), что усложняет парсинг имён при обработке ошибок. - **Отказоустойчивость:** при отсутствии пароля БД в целевом окружении система должна запросить его у пользователя и повторить импорт, а не падать фатально. - **Pre-flight анализ:** dry-run должен вычислять diff и риски без мутации целевого окружения, но с полноценной трансформацией архивов, включая cross-filter patching. --- ## Decision 1: UUID-трансформация БД вместо удаления databases/ из архива ### Как работает механизм Superset идентифицирует базы данных по UUID. При импорте архива Superset вызывает `import_database()` для каждого YAML-файла в `databases/`. Если UUID совпадает с существующей БД в целевом окружении, Superset **пропускает создание** и заполняет `database_ids` — благодаря этому datasets и charts корректно привязываются к существующим БД без запроса пароля. `MigrationEngine._transform_database_yaml()` заменяет UUID исходной БД на UUID целевой БД согласно переданному `db_mapping`. Файлы БД, для которых нет маппинга, остаются без изменений (логируется `keeping as-is`). ### Почему не strip_databases=True При `strip_databases=True` из архива удаляется вся директория `databases/`. Superset при импорте не находит ни одной БД → `database_ids` остаётся пустым → все datasets пропускаются (нет связи с БД) → все charts пропускаются (нет datasets) → `update_id_refs(chart_ids={}, dataset_info={})` падает с `KeyError` → GENERIC_COMMAND_ERROR 1010. **Решение:** `strip_databases=False` всегда. UUID-трансформация — единственный корректный путь. ### Обработка отсутствующего пароля Если целевая БД существует по UUID, но для неё не сохранён пароль, Superset возвращает ошибку: `"Must provide a password for the database"`. Плагин перехватывает эту ошибку и запускает поток password injection (см. Decision 3). --- ## Decision 2: Cross-filter patching через IdMappingService ### Проблема Дашборды Superset могут содержать cross-filter ссылки (в `json_metadata`), которые ссылаются на chart_id других дашбордов. При миграции chart_id меняются (целевой Superset назначает новые ID). Без патчинга cross-filters ссылаются на несуществующие или чужие charts. ### Решение `MigrationEngine._patch_dashboard_metadata()` заменяет source chart_id на target chart_id во всех dashboard YAML-файлах архива. Для этого требуется: 1. `self.mapping_service` (реализует `IdMappingService`) — выполняет batch-запросы для получения target ID по source UUID. 2. `target_env_id` — идентификатор целевого окружения для поиска в mapping-таблицах. Управляется флагом `fix_cross_filters` (по умолчанию `True`). ### Исправление dry-run (2026-07-16) В `MigrationDryRunService.run()` движок создавался без `mapping_service`: ```python engine = MigrationEngine() # ← mapping_service=None ``` Из-за этого cross-filter patching молча пропускался в dry-run, производя неполный pre-flight анализ. Исправлено: ```python engine = MigrationEngine(mapping_service=IdMappingService(db)) ``` --- ## Decision 3: Password injection flow (await_input → wait_for_input → retry) ### Проблема При импорте дашборда может потребоваться пароль БД, который не хранится в системе. Плагин должен запросить пароль у пользователя через UI, дождаться ввода, и повторить импорт с паролем — без падения всей задачи. ### Решение Поток password injection реализован в `MigrationPlugin.execute()`: ``` 1. export_dashboard(dash_id) — экспорт из источника 2. transform_zip(...) — трансформация UUID + cross-filters 3. import_dashboard(...) — импорт в цель ↓ ошибка "Must provide a password" 4. tm.await_input(task_id, { — пауза задачи, статус AWAITING_INPUT type: "database_password", databases: [db_name], error_message: "..." }) 5. tm.wait_for_input(task_id) — блокировка до ответа пользователя 6. task.params["passwords"] — пользователь ввёл пароли через UI 7. import_dashboard(..., passwords=...) — повторный импорт с паролями 8. task.params.pop("passwords") — очистка паролей из памяти ``` **Парсинг имени БД из ошибки** — два regex: - `r"databases/([^.]+)\.yaml"` — для формата `databases/Dev_Clickhouse_Node_1.yaml` (извлекает `Dev_Clickhouse_Node_1` — до первой точки после `/`) - `r"database '([^']+)'"` — для формата `database 'PostgreSQL'` - fallback: `"unknown"` — если ни один regex не сработал ### Исправление бага add_log_callback (2026-07-16) Плагин передавал `add_log_callback=add_log` в `tm.await_input()`. Однако `TaskManager.await_input()` принимает только `(task_id, input_request)` — менеджер **сам** передаёт `add_log_callback=self._add_log` в `lifecycle.await_input()`. Внешняя передача ломала сигнатуру → `TypeError` → фатальный сбой задачи. **Исправление:** убран `add_log_callback` из вызова плагина. Строка `add_log = context._logger._add_log if context else None` удалена как мёртвый код. --- ## Decision 4: Разделение dry-run и execute ### Архитектура | Компонент | Назначение | Мутация цели | |-----------|-----------|:---:| | `MigrationDryRunService` | Pre-flight: export → transform → extract objects → diff → risk score | ❌ read-only | | `MigrationPlugin.execute()` | Полный цикл: export → transform → import → ID sync | ✅ запись | **`MigrationDryRunService`** использует `MigrationArchiveParser` для извлечения объектов (dashboards, charts, datasets) из трансформированного ZIP, сравнивает их с целевым окружением через `_build_target_signatures()`, вычисляет diff (create/update/delete) и риски. **`MigrationPlugin.execute()`** выполняет реальный импорт с обработкой ошибок, password injection, повторными попытками после `wait_for_resolution`, и финальной синхронизацией ID-маппингов через `IdMappingService.sync_environment()`. ### Почему не объединено Объединение dry-run и execute в один код-путь создало бы риск случайной мутации целевого окружения при pre-flight анализе. Разделение гарантирует, что dry-run никогда не выполняет запись. --- ## Decision 5: Парсинг имён YAML-файлов БД Regex для путей БД в ошибках Superset извлекает `databases/.yaml`. UI показывает short name (basename без `.yaml`); при retry ключи нормализуются обратно в path format. **Отклонённая альтернатива:** `r"databases/([^.]+)\.yaml"` alone as password map key — Superset `load_configs` matches full path after `remove_root`, not short names. --- ## Decision 6: Per-dashboard isolation + structured errors + password keys ### Batch isolation Ошибка одного дашборда (export / transform / import / password-retry) **не** прерывает цикл. Запись добавляется в `failed_dashboards`, остальные дашборды продолжают обрабатываться. Итог: `status=PARTIAL_SUCCESS` при любых failures. Исключения не покидают per-dashboard handler (кроме глобальных: env resolve, client init). Password-retry выполняется **внутри** lifetime temp ZIP (не после выхода из `create_temp_file`), иначе архив уже удалён. ### Structured Superset errors `format_superset_import_error()` разбирает SIP-40 payload: ```json {"errors":[{"message":"...","error_type":"GENERIC_COMMAND_ERROR","extra":{...}}]} ``` `failed_dashboards[]` содержит: `error`, `error_detail?`, `error_type?`, `issue_codes?`, `phase`, `id`, `title` — для UI-сводки (имена + тексты Superset). ### Password keys Superset API: `{"databases/MyDatabase.yaml": "password"}`. UI/short names нормализуются через `normalize_superset_passwords()` перед `import_dashboard(..., passwords=...)`. @REJECTED Abort entire migration task on one dashboard import error. @REJECTED Password map keys as short DB names without `databases/*.yaml` prefix. --- ## Последствия 1. **UUID-трансформация обязательна.** Каждый вызов `transform_zip()` должен получать актуальный `db_mapping`. Пустой mapping допустим только когда `replace_db_config=False`. 2. **Cross-filter patching требует mapping_service.** И в dry-run, и в execute `MigrationEngine` должен создаваться с `mapping_service`. Без него cross-filter ссылки останутся несогласованными. 3. **await_input не принимает add_log_callback извне.** Любой плагин, вызывающий `tm.await_input()`, должен передавать только `(task_id, input_request)`. Логирование обрабатывается менеджером внутренне. 4. **Пароли не персистятся.** `task.params["passwords"]` существуют только в памяти во время выполнения задачи и удаляются сразу после успешного импорта или failed password-retry. 5. **Dry-run иммутабелен.** `MigrationDryRunService` никогда не вызывает методы, мутирующие целевой Superset. Это проверяемо: единственные вызовы к target — read-only (`get_dashboards`, `get_datasets`, `get_databases`). 6. **Per-dashboard isolation.** Сбой одного дашборда → `failed_dashboards` + продолжение batch; задача не падает fatal из-за import error. 7. **Password keys = archive paths.** Перед retry всегда `normalize_superset_passwords`. # [/DEF:Doc.Adr.ADR0019:ADR]