docs(adr): comprehensive Superset testcontainers investigation

Add full debugging chronicle (8 steps) documenting the JWT auth root cause:
- False hypotheses eliminated: FAB permissions, CSRF, different servers
- Root cause: Superset ignores SQLALCHEMY_DATABASE_URI env vars entirely
  (uses only superset_config.py via SUPERSET_CONFIG_PATH)
- Three-component fix: psycopg2-binary + superset_config.py + Docker bridge IP
- Comparison table: what works vs what doesn't (8 approaches tested)
- Version comparison: 4.1.2 vs 6.1.0 (6 characteristics)

Remove incorrect FAB-permission hypothesis from earlier version.
This commit is contained in:
2026-06-11 15:24:45 +03:00
parent d24745a01e
commit ff9f8bb54f

View File

@@ -1,16 +1,112 @@
# [DEF:ADR-0012:ADR]
# @STATUS ACTIVE
# @PURPOSE Зафиксировать архитектурное решение об использовании Testcontainers для запуска Apache Superset в интеграционных тестах. Описать двухконтейнерную архитектуру (init + web), выбор версии 4.1.2 и документировать ограничения JWT-авторизации в тестовом окружении.
# @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 Проект ss-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-сервер.
# @RATIONALE КРИТИЧЕСКОЕ ОТКРЫТИЕ (2026-06-11): образ Superset 4.1.2 **не содержит psycopg2** — при указании `SQLALCHEMY_DATABASE_URI=postgresql+psycopg2://...` Superset **молча фоллбечится на SQLite** (`/app/superset_home/superset.db`). При двухконтейнерной архитектуре init-контейнер создаёт таблицы и пользователя в своём SQLite, затем умирает. Web-контейнер стартует с новым пустым SQLite — пользователь admin не существует → `auth_user_db` возвращает None → JWT login: 401 "Not authorized". **Решение:** `python -m pip install psycopg2-binary -q` в init и web контейнерах. После установки драйвера Superset подключается к общему Postgres (`superset_meta`), пользователь виден обоим контейнерам, JWT-авторизация работает.
# @REJECTED Docker Compose для тестов — отвергнут, так как требует предварительного `docker compose up`, порты фиксированы и могут конфликтовать, изоляция между сессиями слабее.
# @REJECTED Superset 6.1.0 (latest) — отвергнут, потому что: (1) образ не содержит драйвер PostgreSQL (`psycopg2`, `pg8000`, `asyncpg` — ни одного), (2) требуется `SUPERSET__SQLALCHEMY_DATABASE_URI` вместо `SQLALCHEMY_DATABASE_URI`, (3) `pip install` в 6.x ставит в системный Python, а Superset использует venv (`/app/.venv/bin/python`) — нужно указывать `/app/.venv/bin/pip`, (4) форма логина падает с `no such table: themes` при использовании SQLite. Суммарно 6.x добавляет 30+ секунд к старту и требует нестандартных workaround'ов.
# @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
### Архитектура контейнеров
@@ -19,35 +115,45 @@
Test Session
├── PostgresContainer (postgres:16-alpine)
│ ├── DB: test_translate (для app-таблиц)
── DB: superset_meta (для метаданных Superset)
── DB: superset_meta (для метаданных Superset)
│ └── Docker bridge IP: 172.17.0.x (не localhost!)
├── Init Container (apache/superset:4.1.2)
│ ├── superset db upgrade → миграции в superset_meta
│ ├── 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 → останавливается
── superset init → роли, permission'ы
└── Статус: запускается → ждём exit code 0 → останавливается
└── Web Container (apache/superset:4.1.2)
── superset run -h 0.0.0.0 -p 8088 --with-threads
Статус: запускается → ждём /health → живёт до конца сессии
── 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 | `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 + init |
| 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) — создаёт БД superset_meta
├── superset_db_url (session) — Docker bridge IP + порт 5432
├── superset_secret_key (session) — SECRET_KEY
├── superset_admin_password (session) — admin123
├── superset_container (session) — init + web контейнеры
@@ -61,28 +167,41 @@ backend/tests/integration/conftest.py
|------|-------|--------|
| 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 | ✅ 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` при старте контейнера.** Образ Superset 4.1.2 не содержит драйвер PostgreSQL. Без него Superset молча фоллбечится на SQLite, что ломает двухконтейнерную архитектуру (init-контейнер теряет данные). `pip install` добавляет ~5s к холодному старту. Первый запуск также требует загрузки пакета из PyPI.
1. **Требуется `pip install psycopg2-binary` при старте контейнера.** Образ 4.1.2
не содержит драйвер PostgreSQL. Без него Superset молча фоллбечится на SQLite,
что ломает двухконтейнерную архитектуру (init теряет данные). `pip install`
добавляет ~5s к холодному старту.
2. **Версия 6.1.0 несовместима без доработок.** Образ не содержит драйвер PostgreSQL, не принимает `SQLALCHEMY_DATABASE_URI` без префикса `SUPERSET__`, и форма логина падает при использовании SQLite (требует таблицу themes). `pip install` требует указания `/app/.venv/bin/pip`.
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 с обоснованием.
- При добавлении тестов на JWT-авторизацию сначала решить проблему с FAB-permission'ами (возможно, через `superset fab` команду или кастомный скрипт инициализации).
- Все новые тесты, проверяющие взаимодействие с 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 pip install psycopg2-binary`
2. Замена `SQLALCHEMY_DATABASE_URI` на `SUPERSET__SQLALCHEMY_DATABASE_URI`
3. Возможно — монтирование `superset_config.py` для тонкой настройки
4. Пересмотр JWT-авторизации — API мог измениться
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]