# [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]