diff --git a/docs/adr/ADR-0012-superset-testcontainers.md b/docs/adr/ADR-0012-superset-testcontainers.md index 3aa79ae7..f0d4ff33 100644 --- a/docs/adr/ADR-0012-superset-testcontainers.md +++ b/docs/adr/ADR-0012-superset-testcontainers.md @@ -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]