- 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
208 lines
16 KiB
Markdown
208 lines
16 KiB
Markdown
# [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]
|