Files
ss-tools/docs/adr/ADR-0019-dashboard-import-mechanism.md
root 632b730fff chore: migrate GRACE-Poly anchors to hierarchical dotted naming
Systematic rename of all semantic anchors (#region, [DEF], @RELATION)
across 1400+ files — backend Python, frontend Svelte/TS, specs, docs:
- Flat anchors become Namespace.Module.Entity
- @RELATION references updated to match new anchor paths
- Zero business logic changes
2026-07-22 11:48:15 +03:00

14 KiB
Raw Blame History

[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:

engine = MigrationEngine()  # ← mapping_service=None

Из-за этого cross-filter patching молча пропускался в dry-run, производя неполный pre-flight анализ. Исправлено:

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/<file>.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:

{"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]