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

244 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.

# [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/<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:
```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]