Files
ss-tools/docs/adr/ADR-0012-superset-testcontainers.md
busya ec6421de35 rename ss-tools to superset-tools across the entire project
- Replace all occurrences of 'ss-tools' with 'superset-tools' in 104 files
- Rename git bundle file ss-tools.bundle → superset-tools.bundle
- Update .gitignore pattern accordingly
- Preserve variable names (hasSsTools etc.) and code identifiers
2026-06-16 11:15:19 +03:00

208 lines
16 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:ADR-0012:ADR]
# @STATUS ACTIVE
# @PURPOSE Зафиксировать архитектурное решение об использовании Testcontainers для запуска Apache Superset в интеграционных тестах. Описать двухконтейнерную архитектуру (init + web), расследование проблем аутентификации, выбор версии 4.1.2, решение проблемы JWT-авторизации через psycopg2 + superset_config.py.
# @RELATION DEPENDS_ON -> [ADR-0005-auth-rbac]
# @RELATION DEPENDS_ON -> [ADR-0001-module-layout]
# @RATIONALE Проект superset-tools интенсивно взаимодействует с Superset REST API (SupersetClient, SupersetSqlLabExecutor, translate-плагины). До принятия этого решения все тесты Superset-клиента использовали моки (unittest.mock.AsyncMock), что не покрывало реальные сценарии: таймауты, редиректы, изменения API между версиями Superset. Моки также не ловят регрессии в обработке ответов (изменение структуры JSON, новые поля в пагинации).
# @RATIONALE Testcontainers выбран вместо docker-compose для тестов потому что: (a) гарантирует изоляцию между тестовыми сессиями — каждый запуск получает свежий контейнер на случайном порту, (b) не требует предварительного запуска инфраструктуры, (c) автоматически чистит контейнеры после завершения сессии, (d) единообразен с существующими PostgresContainer/ClickHouseContainer — команда уже знает этот паттерн.
# @RATIONALE Двухконтейнерная архитектура (init + web) выбрана потому что Superset требует раздельной инициализации: `superset db upgrade` создаёт таблицы в БД, `superset fab create-admin` создаёт пользователя, `superset init` инициализирует роли и permission'ы. Только после этого можно запускать веб-сервер. Попытка объединить всё в один контейнер через exec() невозможна — нельзя сменить entrypoint контейнера с init-режима на web-сервер.
# @REJECTED Docker Compose для тестов — отвергнут, так как требует предварительного `docker compose up`, порты фиксированы и могут конфликтовать, изоляция между сессиями слабее.
# @REJECTED Superset 6.1.0 (latest) — отвергнут. См. раздел «Расследование: почему не 6.x».
# @REJECTED Superset версии 5.x — не существует. Нумерация перескочила с 4.x на 6.x.
## Расследование: почему JWT-авторизация не работала
### Хронология отладки (2026-06-11)
**Шаг 1 — Симптом:** `POST /api/v1/security/login` → 401 `{"message":"Not authorized"}`
при правильных креденшелах (admin:admin123). Form-based логин (`POST /login/`) работал,
но `/api/v1/me/` с session cookie — 401.
**Шаг 2 — Ложная гипотеза (FAB permissions):** Проверено, что пользователь admin существует,
активен, пароль верен. `auth_user_db('admin', 'admin123')` возвращает пользователя.
Flask test client внутри контейнера успешно выполняет JWT login. Значит, проблема
не в permissions Flask-AppBuilder, а на уровне HTTP/сети.
**Шаг 3 — Ложная гипотеза (CSRF):** Проверен CSRF token из session cookie —
не помогает. Проверен `X-CSRFToken` header — не помогает.
**Шаг 4 — Ложная гипотеза (разные серверы):** Проверено, что `docker run -p`
(прямой Docker) работает, а testcontainers — нет. Разница оказалась в том,
что при `docker run -p` init и web — один контейнер, а у нас — два разных.
**Шаг 5 — Прорыв:** Проверка: существует ли пользователь admin в web-контейнере?
```
User found: False
NO USER
```
Web-контейнер НЕ ВИДИТ пользователя, созданного init-контейнером.
**Шаг 6 — Корневая причина:** Superset 4.1.2 **игнорирует** `SQLALCHEMY_DATABASE_URI`
из переменных окружения. Даже при установленном `SUPERSET__SQLALCHEMY_DATABASE_URI`
(с префиксом) конфиг всегда возвращает:
```
SQLALCHEMY_DATABASE_URI: sqlite:////app/superset_home/superset.db?check_same_thread=false
```
Superset загружает дефолтный конфиг из `superset/config.py`, который хардкодит SQLite.
Переменные окружения НЕ переопределяют этот параметр (ни с префиксом `SUPERSET__`, ни без).
**Шаг 7 — Следствие:** При двухконтейнерной архитектуре:
- Init-контейнер: `superset db upgrade` → создаёт таблицы в своём SQLite
- Init-контейнер: `superset fab create-admin` → создаёт admin в своём SQLite
- Init-контейнер: умирает → файловая система (включая `/app/superset_home/superset.db`) уничтожается
- Web-контейнер: стартует с новым пустым SQLite → пользователь admin не существует
- `auth_user_db('admin', 'admin123')``find_user` возвращает None → 401 "Not authorized"
**Шаг 8 — Решение (три компонента):**
1. **psycopg2-binary:** `python -m pip install psycopg2-binary -q` в init и web контейнерах.
В Superset 4.1.2 Python — `/usr/local/bin/python`, pip install работает корректно.
(В 6.1.0 Python в venv: `/app/.venv/bin/python`, требует `/app/.venv/bin/pip`.)
2. **superset_config.py:** Superset НЕ читает `SQLALCHEMY_DATABASE_URI` из env vars
ни в какой форме. Единственный способ переопределить — создать `superset_config.py`
и указать `SUPERSET_CONFIG_PATH`. Конфиг пишется через heredoc в `/tmp/superset_config.py`
и читает `SUPERSET_DB_URI` из переменной окружения:
```python
import os
SQLALCHEMY_DATABASE_URI = os.environ.get("SUPERSET_DB_URI", "sqlite://")
```
Альтернативы, которые НЕ работают: `SQLALCHEMY_DATABASE_URI` env var,
`SUPERSET__SQLALCHEMY_DATABASE_URI` env var, `DATABASE_URL` env var.
3. **Docker bridge IP:** `postgres_container.get_connection_url()` возвращает
`localhost:PORT`. Изнутри Superset-контейнера `localhost` — это сам контейнер,
а не хост. Нужно использовать Docker bridge IP Postgres-контейнера (`172.17.0.x`),
полученный через `get_wrapped_container().attrs["NetworkSettings"]["Networks"]["bridge"]["IPAddress"]`.
Порт — внутренний (5432), не exposed.
### Таблица: что работает, а что нет
| Подход | Результат | Причина |
|--------|-----------|---------|
| `SQLALCHEMY_DATABASE_URI` env var | ❌ Игнорируется | `superset/config.py` хардкодит SQLite |
| `SUPERSET__SQLALCHEMY_DATABASE_URI` env var | ❌ Игнорируется | Тоже не переопределяет дефолт |
| `DATABASE_URL` env var | ❌ Игнорируется | Superset не использует эту переменную |
| `superset_config.py` через `SUPERSET_CONFIG_PATH` | ✅ Работает | Единственный способ переопределить конфиг |
| `localhost` в URI изнутри контейнера | ❌ Connection refused | `localhost` внутри контейнера — сам контейнер |
| Docker bridge IP (`172.17.0.x`) | ✅ Работает | Контейнеры на одном bridge network |
| JWT login через Flask test client | ✅ 200 | Нет сетевого слоя, прямой вызов |
| JWT login через `curl` изнутри контейнера | ✅ 200 | `localhost` внутри контейнера — сам контейнер (и DB в одном контейнере) |
| JWT login через `curl` с хоста (docker run -p) | ✅ 200 | Один контейнер для init+web, DB не теряется |
| Form login (`POST /login/`) без DB | ✅ 200 | Работает даже с SQLite (пока не нужен themes) |
| `/api/v1/me/` с session cookie | ❌ 401 | REST API требует JWT Bearer, не session |
### Сравнение версий Superset
| Характеристика | 4.1.2 | 6.1.0 |
|---------------|-------|-------|
| Python | `/usr/local/bin/python` (3.10) | `/app/.venv/bin/python` (3.10) |
| psycopg2 | ❌ нет | ❌ нет |
| pip install работает | ✅ `python -m pip` | ⚠️ нужен `/app/.venv/bin/pip` |
| `SQLALCHEMY_DATABASE_URI` env | ❌ игнорируется | ❌ игнорируется |
| `SUPERSET__SQLALCHEMY_DATABASE_URI` env | ❌ игнорируется | ✅ работает |
| `superset_config.py` | ✅ работает | ✅ работает |
| SQLite fallback | Молча (без ошибок) | Падает: `no such table: themes` |
| `/health` на SQLite | ✅ "OK" | ✅ "OK" |
| `/login/` на SQLite | ✅ 200 | ❌ 500 (themes table) |
| Вердикт | **Выбрана** | Отвергнута |
## Decision
### Архитектура контейнеров
```
Test Session
├── PostgresContainer (postgres:16-alpine)
│ ├── DB: test_translate (для app-таблиц)
│ ├── DB: superset_meta (для метаданных Superset)
│ └── Docker bridge IP: 172.17.0.x (не localhost!)
├── Init Container (apache/superset:4.1.2)
│ ├── python -m pip install psycopg2-binary -q
│ ├── cat > /tmp/superset_config.py (читает SUPERSET_DB_URI)
│ ├── export SUPERSET_CONFIG_PATH=/tmp/superset_config.py
│ ├── superset db upgrade → миграции в superset_meta (через psycopg2)
│ ├── superset fab create-admin → admin:admin123
│ ├── superset init → роли, permission'ы
│ └── Статус: запускается → ждём exit code 0 → останавливается
└── Web Container (apache/superset:4.1.2)
├── python -m pip install psycopg2-binary -q
├── cat > /tmp/superset_config.py (читает SUPERSET_DB_URI)
├── export SUPERSET_CONFIG_PATH=/tmp/superset_config.py
├── superset run -h 0.0.0.0 -p 8088 --with-threads
└── Статус: запускается → ждём /health → живёт до конца сессии
```
### Ключевые параметры
| Параметр | Значение | Обоснование |
|----------|----------|-------------|
| Версия Superset | `4.1.2` | Работает с psycopg2 + superset_config.py |
| PostgreSQL | `16-alpine` | Общий с app-тестами, отдельная БД `superset_meta` |
| psycopg2 | Установка при старте | `python -m pip install psycopg2-binary -q` |
| Конфиг | `/tmp/superset_config.py` | Через `SUPERSET_CONFIG_PATH` |
| DB host | Docker bridge IP | `172.17.0.x`, не `localhost` |
| SECRET_KEY | Фиксированный `test-...` | Детерминированное поведение |
| Admin password | `admin123` | Только для тестового контейнера |
| Startup timeout | 90s | Достаточно для cold-start pull + pip install + init |
| Health check | `GET /health` → 200 + `"OK"` | Самый лёгкий эндпоинт Superset |
### Fixtures в conftest
```
backend/tests/integration/conftest.py
├── superset_db_url (session) — Docker bridge IP + порт 5432
├── superset_secret_key (session) — SECRET_KEY
├── superset_admin_password (session) — admin123
├── superset_container (session) — init + web контейнеры
├── superset_url (function) — http://host:port
└── superset_admin_headers (function) — requests.Session с form-логином
```
### Что тестируется
| Слой | Тесты | Статус |
|------|-------|--------|
| Health | `/health`, `/login/`, `/api/v1/database/` | ✅ 4/4 |
| Auth (form) | Логин → session cookie, `/api/v1/me/` | ✅ 2/2 |
| Auth (JWT) | `/api/v1/security/login` → access_token + refresh_token | ✅ 1/1 |
| API (JWT) | `/api/v1/dashboard/` с Bearer token | ✅ 1/1 |
| SupersetClient | Конструкция клиента, api_base_url | ✅ 2/2 |
### Ограничения
1. **Требуется `pip install psycopg2-binary` при старте контейнера.** Образ 4.1.2
не содержит драйвер PostgreSQL. Без него Superset молча фоллбечится на SQLite,
что ломает двухконтейнерную архитектуру (init теряет данные). `pip install`
добавляет ~5s к холодному старту.
2. **Требуется `superset_config.py`.** Superset не читает `SQLALCHEMY_DATABASE_URI`
из переменных окружения ни в какой форме — только через файл конфигурации.
3. **Требуется Docker bridge IP.** `localhost` недоступен изнутри контейнера.
Нужно получать реальный IP через Docker API.
4. **Версия 6.1.0 несовместима без доработок.** Образ не содержит драйвер PostgreSQL,
требует `SUPERSET__` префикс для env vars (но не для `SQLALCHEMY_DATABASE_URI`
— только `superset_config.py`), форма логина падает при использовании SQLite.
## Enforcement
- Все новые тесты, проверяющие взаимодействие с Superset REST API, должны использовать
`superset_container` fixture из `tests/integration/conftest.py`.
- Версия образа (`4.1.2`) зафиксирована. Изменение версии требует нового ADR.
- Три компонента запуска (psycopg2, superset_config.py, Docker bridge IP) являются
обязательными — удаление любого из них ломает JWT-авторизацию.
## Migration Path к Superset 6.x
При переходе на Superset 6.x потребуется:
1. Кастомный Dockerfile: `FROM apache/superset:6.x` + `RUN /app/.venv/bin/pip install psycopg2-binary`
2. `superset_config.py` через `SUPERSET_CONFIG_PATH` (обязательно — env vars не работают)
3. Docker bridge IP для Postgres (без изменений)
4. Пересмотр JWT-авторизации — API не изменился, но образ нестабилен на SQLite
# [/DEF:ADR-0012:ADR]