diff --git a/AGENTS.md b/AGENTS.md index 84512a5..369383d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,8 @@ Airflow) и названия из кода. Если для понятия ес - Python — только через `uv`; проверка и формат — `ruff` (`make lint`). - Изменения держать минимальными и в границах задания. +- Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук + обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md). - Секреты не коммитить: настройки — через `.env`, образец — `.env.example`. - При изменении инфраструктуры или DDL обновлять документацию тем же PR. - Коммиты — Conventional Commits: заголовок `type(scope): результат`, тело на diff --git a/Makefile b/Makefile index c2b32f7..0d9d51e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ COMPOSE ?= docker compose -.PHONY: up down clean ps logs config-test lint typecheck test docs smoke smoke-cluster +.PHONY: up down clean ps logs config-test lint typecheck test docs smoke check-clickhouse check-services up: $(COMPOSE) up --detach --build --wait --wait-timeout 600 @@ -36,5 +36,8 @@ docs: smoke: COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh -smoke-cluster: +check-clickhouse: ./scripts/clickhouse-smoke.sh + +check-services: + COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-services.sh diff --git a/README.md b/README.md index 8af8a43..66e468e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Prometheus, Grafana и общая база Postgres для метаданных. пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не хватает, стенд одинаково хорошо живёт на недорогом VPS. -Полная проверка также использует `curl`, `jq`, `awk`, +Проверкам на поднятом стенде также нужны `curl`, `jq`, `awk`, `grep`, `sed`, `tail`, `sleep` и `timeout`. По умолчанию должны быть свободны порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090` и `29092`. Проверкам без стенда — `make config-test`, `make lint`, @@ -34,8 +34,15 @@ Prometheus, Grafana и общая база Postgres для метаданных. ```bash make up make smoke +make check-clickhouse +make check-services ``` +Первому знакомству нужны все три проверки на стенде: `make smoke` говорит, что +стенд собран, `make check-clickhouse` — что кластер работает кластером, а +`make check-services` — что Airflow запускает DAG, а Superset ходит в базу. +Дальше, в рабочей петле, обычно хватает `make smoke`. + Чтобы изменить образы, порты или учебные учётные данные, скопируйте образец: ```bash @@ -62,17 +69,12 @@ make smoke подготавливает администратора и подключение к `clickhouse-01`. Второй обновляет Superset, создаёт администратора и импортирует подключение к `clickhouse-02`. -`make smoke` проверяет зависимости машины, здоровье контейнеров, устройство -keeper, Kafka через порт машины, три цели Prometheus, источник Grafana, -компоненты Airflow, ручной запуск пробников `test_clickhouse` и `test_kafka`, -метаданные и подключение Superset. Первый пробник создаёт -таблицы на обеих нодах и читает через `Distributed` на ноде 2 строку из -локальной таблицы ноды 1. Второй пишет в Kafka и читает свой маркер. В конце +`make smoke` за секунды спрашивает, собран ли стенд: зависимости машины, +здоровье контейнеров, устройство keeper, три цели Prometheus, источник Grafana, +компоненты Airflow и подготовленное подключение к `clickhouse-01`. В конце проверка спрашивает у Docker, не убивало ли ядро что-нибудь в долгоживущих контейнерах за нехватку памяти и не включалась ли политика перезапуска: убитый контейнер Docker поднимает сам, и проверка состояния об этом промолчит. -Временный топик проверки с машины и запуски DAG удаляются; -постоянный топик пробника сохраняется, а старые записи чистит Kafka. Одиннадцать проверок здоровья сразу после `make up --wait` повторяют то, чего Compose уже дождался: у каждой долгоживущей службы есть своя `healthcheck`. @@ -83,37 +85,25 @@ Compose уже дождался: у каждой долгоживущей слу выглядит и без этого, поэтому смоук спрашивает у живого контейнера, дошли ли объявленные настройки до процесса. -`make smoke-cluster` запускает отдельную глубокую проверку ClickHouse: описание -кластера, макросы, связь с keeper, `ReplicatedMergeTree`, `Distributed`, очередь -распределённых DDL и очистку временных таблиц. +`make check-services` проверяет то, ради чего приходится ждать службу: Kafka +через порт машины, ручной запуск пробников `test_clickhouse` и `test_kafka`, +вход в Superset, его метаданные и подключение к `clickhouse-02`. Первый пробник +создаёт таблицы на обеих нодах и читает через `Distributed` на ноде 2 строку из +локальной таблицы ноды 1. Второй пишет в Kafka и читает свой маркер. Временный +топик проверки с машины и запуски DAG удаляются; постоянный топик пробника +сохраняется, а старые записи чистит Kafka. + +`make check-clickhouse` запускает отдельную глубокую проверку ClickHouse: +описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`, +`Distributed`, очередь распределённых DDL и очистку временных таблиц. `make config-test` проверяет Compose, синтаксис Bash и Python и пробельные ошибки в diff без запуска стенда. -Правило, которое стоит держать в голове, правя любую из этих проверок: -**проверка, которая не умеет краснеть, бесполезна.** Проверка, никогда не -видевшая своей поломки, доказывает только то, что она умеет печатать «ЗЕЛЁНО». -Убедиться дешевле всего руками: сломайте то, что она стережёт — остановите -`prometheus`, удалите служебную таблицу пробника на второй ноде, — и посмотрите, -покраснеет ли прогон и назовёт ли виновника. Не покраснел — проверка не -работает, и чинить надо её, а не стенд. - -### Какую проверку когда запускать - -Проверки выстроены лесенкой: чем дороже прогон, тем больше связей он трогает. - -- `make config-test` — секунды, стенд поднимать не нужно. Видит только то, что - есть в файлах, и о работоспособности не говорит ничего. Дёшево настолько, что - можно гонять перед каждым коммитом. -- `make lint` и `make test` — тоже секунды и тоже без стенда, но про другой - код: ruff и тесты генератора в `generator/`. Тесты сторожат контракт схемы - события и свежесть собранного из него описания выгрузки. -- `make smoke` — минута-две на поднятом стенде. Дороже, но проверяет связи - между службами, а не отдельные файлы: это интеграционная проверка. -- `make smoke-cluster` — около минуты. Одна связь, зато до дна: межнодовое - устройство ClickHouse. - -Обычный рабочий цикл — `make config-test` и `make smoke`. +Обычный рабочий цикл — `make config-test` и `make smoke`; остальные цели гоняют +тогда, когда правка их касается. Что утверждает каждая проверка, нужен ли ей +поднятый стенд, сколько стоит прогон и куда класть новую — в +[карте проверок](docs/architecture/testing.md). Остановить контейнеры без удаления данных можно командой `make down`. Для полного сброса с удалением всех именованных томов используйте `make clean`. @@ -218,8 +208,10 @@ Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, ф - [docs/adr/](docs/adr/) — принятые решения с доводами и отвергнутыми вариантами: почему сделано так, а не иначе. - [docs/architecture/](docs/architecture/) — рабочие справочники по зонам - ответственности; сейчас это [хранилище](docs/architecture/storage.md): - конвенции имён, раскладка по шардам, приём событий, карта таблиц. + ответственности: [хранилище](docs/architecture/storage.md) — конвенции имён, + раскладка по шардам, приём событий, карта таблиц; + [проверки](docs/architecture/testing.md) — что утверждает каждая цель `make` + и куда класть новую проверку. - [docs/research/](docs/research/) — исследования; сейчас это формат кликстрима Яндекса, по которому строится модель события. - [docs/formats/](docs/formats/) — описания форматов источников: по ним diff --git a/docs/architecture/testing.md b/docs/architecture/testing.md new file mode 100644 index 0000000..b391aee --- /dev/null +++ b/docs/architecture/testing.md @@ -0,0 +1,150 @@ +# Проверки: карта целей + +Документ отвечает на два вопроса. Первый: что именно утверждает каждая цель +`make` и сколько стоит её прогон. Второй, ради которого документ и заведён: +куда положить новую проверку — так, чтобы это решалось по карте, а не чтением +скриптов. + +Зона ответственности у документа одна — проверки. Что именно они стерегут, +описано в других местах: устройство хранилища — в +[storage.md](storage.md), замысел стенда — в спеке [«Боевой реализм стенда +(v2)»](../specs/2026-07-30-stand-v2-realism.md). + +## Ось: кого спрашивают + +Цели различаются не ценой, а тем, к кому обращён вопрос. ClickHouse отвечает +сам и за миллисекунды. Airflow отвечает через такт планировщика. Superset — +через сессию и обход метаданных. Быстрота выходит следствием этого различия, а +не критерием деления. + +Совпадение цены и назначения здесь побочное, и это видно по дырам, которые +каждая цель оставляет соседке. `make smoke` не заметит перепутанных местами +макросов `shard`: обе ноды здоровы и порты отвечают. `make check-clickhouse` не +заметит потерянного подключения Superset: он про ClickHouse и только. + +Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Договор со +схемой событий — вопрос к ClickHouse, значит дом ему в `check-clickhouse`, даже +если по цене он подошёл бы смоуку. Счётчики против манифеста — тоже вопрос к +ClickHouse: строки в `ods.event` считает сам сервер и отвечает сразу. + +## Карта целей + +Стенд нужен трём целям из семи. Цена — замер 6 августа 2026 года, см. «Что +проверено». + +| Цель | Что утверждает | Стенд | Цена | +|---|---|---|---| +| `make lint` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с | +| `make typecheck` | Типы генератора сходятся (ty) | не нужен | 0,5 с | +| `make test` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранное из неё [описание выгрузки](../formats/clickstream-event.md) — свежим | не нужен | 40 с | +| `make config-test` | Compose разбирается, Bash и Python синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с | +| `make smoke` | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 6 с | +| `make check-clickhouse` | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL | нужен | 7 с | +| `make check-services` | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 44 с | + +## Правило: смоук обязан оставаться быстрым + +`make smoke` — быстрая проверка для регулярного прогона: её гоняют не +задумываясь, и потому она обязана укладываться в секунды. Тяжёлые проверки в +неё включать не следует: тяжёлая — та, из-за которой смоук перестаёт быть +быстрым. Поодиночке это единицы и десятки секунд, вместе — минуты. Такие +проверки живут в `make check-services`. + +На практике дорого обходится не работа, а ожидание службы: такт планировщика +Airflow, сессия Superset. `make check-clickhouse` создаёт таблицы, вставляет +строки и гоняет распределённые DDL — и укладывается в семь секунд, потому что +ClickHouse отвечает сразу. + +Первый абзац — правило, второй — наблюдение, по которому тяжёлую проверку +узнают заранее, не замеряя. + +## Какую проверку когда запускать + +Деление целей ценно ровно до тех пор, пока оно не превратилось в «гонять +всегда всё». + +| Когда | Что гонять | +|---|---| +| Правка в работе | `make config-test` и `make smoke` | +| PR или задача | плюс цели, которых правка касалась: DAG-и, Superset или Kafka — `check-services`; DDL, кластер или данные — `check-clickhouse` | +| Приёмка этапа | `make up` с нуля и все три цели на стенде | + +Правки генератора добавляют к этому `make lint`, `make typecheck` и +`make test`: стенд им не нужен, а `make test` из них самая дорогая. + +## Порогов по времени здесь нет + +Цена в таблице — замеренное число с датой замера, а не назначенный порог. +Автоматической проверки времени в репозитории нет и заводить её не следует. +Число, вписанное в проверку, становится законом, которого никто не выбирал: +[ADR 0004](../adr/0004-resource-limits.md) разбирает ровно этот случай — оценка +из спеки попала жёстким порогом в `make smoke`, `make smoke` стал критерием +приёмки каждого этапа, и дальше решения сверялись уже с порогом, а не с +исходным доводом. Спека генератора формулирует ту же позицию прямо: +«наблюдаемость без порогов». + +Смотрит на время человек. `make smoke` и `make check-services` печатают его +сами — последней строкой `ИТОГ: пройдено N, ошибок M, время T с`. Замер при +приёмке делается руками и называется в теле PR. + +## Проверка, которая не умеет краснеть, бесполезна + +Правило, которое стоит держать в голове, правя любую проверку. Проверка, +никогда не видевшая своей поломки, доказывает только то, что она умеет печатать +«ЗЕЛЁНО». Убедиться дешевле всего руками: сломайте то, что она стережёт — +остановите `prometheus`, удалите служебную таблицу пробника на второй ноде, — и +посмотрите, покраснеет ли прогон и назовёт ли виновника. Не покраснел — +проверка не работает, и чинить надо её, а не стенд. + +## Устройство скриптов + +| Цель | Скрипт | +|---|---| +| `make config-test` | `scripts/config-test.sh` | +| `make smoke` | `scripts/stand-smoke.sh` | +| `make check-services` | `scripts/stand-services.sh` | +| `make check-clickhouse` | `scripts/clickhouse-smoke.sh` | + +Имя `clickhouse-smoke.sh` осталось от прежнего имени цели — `smoke-cluster`. +Файл переименуют при следующем касании: сейчас в него встраивается проверка +договора со схемой, и переименование устроило бы конфликт на ровном месте. + +Общее у смоука и `check-services` — счёт проверок, обращение к Compose и две +проверки — вынесено в `scripts/stand-common.sh`; сам он не запускается. +Оттуда же приходят два решения, которые видно по счёту прогонов: + +- **`check_containers_survived` стоит в конце обеих целей.** Убитый за память + контейнер Docker поднимает сам, и проверка здоровья об этом промолчит + (ADR 0004). Стенд нагружает `check-services`, а увидеть последствия нужно и + тому, кто гонял один смоук. Это единственная проверка, которая считается + дважды: 19 у смоука плюс 7 у `check-services` — это 25 разных проверок. +- **Зависимости машины считает только смоук.** «На машине есть Docker, curl и + jq» — вопрос к машине, а не к службам, и на оси он стоит рядом с «стенд + собран». Для `check-services` это условие запуска: без них он не начнёт + работу и громко скажет об этом, но ЗЕЛЁНО за это не печатает. + +Вход в Airflow `check-services` выполняет заново — общих переменных у двух +скриптов нет. Отдельной проверкой этот вход тоже не считается: то же самое +утверждает смоук. + +## Что проверено + +Замеры 6 августа 2026 года, стенд поднят заранее; время `make up` в цену целей +не входит. Время взято по `time` и совпадает с тем, что цель печатает сама. Оно +плавает от прогона к прогону: смоук дал 5 и 6 секунд, `check-services` — 43 и +44. В таблице стоит большее из замеренных. + +До деления `scripts/stand-smoke.sh` шёл 48 секунд на 25 проверок, из них +42 секунды съедали шесть: Kafka с машины, два запуска пробников Airflow и три +проверки Superset. После деления те же 25 проверок разошлись по двум целям: +19 в смоуке и 6 в `check-services`. Содержание ни одной из них не менялось. + +Что обе разделённые цели умеют краснеть, проверено руками в тот же день: со +снятым `prometheus` смоук дал три ошибки и ненулевой код возврата; с +подменённым ожидаемым UUID подключения Superset так же покраснел +`check-services`. Обе краснеют и когда на машине не хватает команды из списка +зависимостей. + +Семантика счётчиков Docker `OOMKilled` и `RestartCount`, на которой держится +`check_containers_survived`, снята отдельными контейнерами и записана в +ADR 0004, раздел «Что проверено». diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index a1d7964..48c3413 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -547,8 +547,11 @@ v2 стартует пустым, поэтому объём ниже — это 9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов и границы — ADR 0002. -Критерий приёмки этапа — честный: `make up` работает и проходят -smoke-проверки, а не «дашборд зелёный». Это минимальная планка; свои +Критерий приёмки этапа — честный: `make up` работает и проходят все три +проверки на стенде — `make smoke`, `make check-clickhouse` и +`make check-services`, — а не «дашборд зелёный». Названы они поимённо +намеренно: под общим словом «smoke-проверки» планка тихо опустилась бы при +следующем делении целей. Это минимальная планка; свои наблюдаемые критерии каждый этап получает при разбиении в /to-tickets. Документация правится в PR этапа (правило AGENTS.md). diff --git a/scripts/clickhouse-smoke.sh b/scripts/clickhouse-smoke.sh index 15432b9..94e6357 100755 --- a/scripts/clickhouse-smoke.sh +++ b/scripts/clickhouse-smoke.sh @@ -65,14 +65,14 @@ on_exit() { if [[ "$status" -ne 0 ]]; then printf 'Сбой проверки: выполняется быстрая очистка временных таблиц...\n' >&2 fi - cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make smoke-cluster.\n' >&2 + cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make check-clickhouse.\n' >&2 exit "$status" } on_signal() { trap - EXIT INT TERM printf 'Проверка прервана пользователем: выполняется быстрая очистка временных таблиц...\n' >&2 - cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make smoke-cluster.\n' >&2 + cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make check-clickhouse.\n' >&2 exit 130 } diff --git a/scripts/stand-common.sh b/scripts/stand-common.sh new file mode 100644 index 0000000..0069a4e --- /dev/null +++ b/scripts/stand-common.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Общая часть проверок на поднятом стенде: счёт проверок, обращение к Compose, +# зависимости машины и вопрос Docker, пережили ли контейнеры прогон. Файл не +# запускается сам — его подключают через source из stand-smoke.sh и +# stand-services.sh. + +readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +read -r -a COMPOSE_CMD <<<"${COMPOSE_BIN:-docker compose}" +readonly -a LONG_LIVED_SERVICES=( + clickhouse-keeper clickhouse-01 clickhouse-02 kafka postgres-metadata + airflow-apiserver airflow-scheduler airflow-dag-processor + superset prometheus grafana +) + +passed=0 +failed=0 + +compose() { + "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" "$@" +} + +pass() { + passed=$((passed + 1)) + printf 'ЗЕЛЁНО: %s.\n' "$1" +} + +fail() { + failed=$((failed + 1)) + printf 'ОШИБКА: %s.\n' "$1" >&2 +} + +published_port() { + local binding + local service="$1" + local container_port="$2" + binding="$(compose port "$service" "$container_port" 2>/dev/null || true)" + printf '%s\n' "${binding##*:}" +} + +# Отвечает, есть ли на машине всё, без чего проверкам не с чем работать. +# Засчитывать ли этот ответ проверкой, решает вызывающий: смоук печатает за него +# ЗЕЛЁНО, потому что вопрос к машине стоит рядом с «стенд собран»; для +# check-services это условие запуска, и своего ЗЕЛЁНО у него там нет. +require_host_dependencies() { + local command + local -a missing=() + + for command in docker curl jq awk grep sed tail sleep timeout; do + command -v "$command" >/dev/null 2>&1 || missing+=("$command") + done + if [[ "${#missing[@]}" -gt 0 ]]; then + fail "на машине не хватает команд: ${missing[*]}" + return 1 + fi + if ! docker compose version >/dev/null 2>&1; then + fail 'Docker Compose недоступен; установите модуль compose для Docker' + return 1 + fi + if ! docker info >/dev/null 2>&1; then + fail 'Docker недоступен; запустите Docker и проверьте доступ к его сокету' + return 1 + fi + return 0 +} + +# Убитый за память контейнер Docker поднимает сам, и проверка здоровья об этом +# промолчит. Порога здесь нет: это «да или нет», а не бюджет памяти (ADR 0004). +# Проверка стоит в конце обеих целей: нагружает стенд check-services, а увидеть +# последствия нужно и тому, кто гонял один смоук. +check_containers_survived() { + local container_id + local service + local state + local problems=0 + + for service in "${LONG_LIVED_SERVICES[@]}"; do + container_id="$(compose ps --all --quiet "$service" 2>/dev/null || true)" + if [[ -z "$container_id" || "$container_id" == *$'\n'* ]]; then + fail "не удалось получить контейнер ${service} для проверки перезапусков" + return + fi + state="$(docker inspect --format '{{.State.OOMKilled}}/{{.RestartCount}}' "$container_id" 2>/dev/null || true)" + case "$state" in + false/0) ;; + # OOMKilled встаёт и когда убит процесс внутри живого контейнера. + true/*) + fail "в контейнере ${service} ядро убило процесс из-за нехватки памяти" + problems=$((problems + 1)) + ;; + false/*) + fail "контейнер ${service} перезапускался, счётчик Docker — ${state#*/}" + problems=$((problems + 1)) + ;; + *) + fail "Docker не рассказал о состоянии контейнера ${service}" + problems=$((problems + 1)) + ;; + esac + done + if [[ "$problems" -eq 0 ]]; then + pass 'ни в одном долгоживущем контейнере ядро не убивало процессы за память, и никто не перезапускался сам' + fi +} + +# Время печатается как замер, а не как порог: смотрит на него человек. +print_total() { + printf 'ИТОГ: пройдено %d, ошибок %d, время %d с\n' "$passed" "$failed" "$SECONDS" +} diff --git a/scripts/stand-services.sh b/scripts/stand-services.sh new file mode 100755 index 0000000..9d701d4 --- /dev/null +++ b/scripts/stand-services.sh @@ -0,0 +1,314 @@ +#!/usr/bin/env bash +# make check-services — службы работают: DAG запускается и доходит, топик +# создаётся и удаляется, Superset логинится и ходит в базу. Здесь собрано всё, +# что ждёт службу, поэтому цель идёт десятки секунд. Карта целей — +# docs/architecture/testing.md. +set -uo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/stand-common.sh" || exit 1 + +kafka_image='' +kafka_port='' +kafka_cleanup_topic='' +airflow_port='' +airflow_token='' +airflow_cleanup_dag_id='' +airflow_cleanup_original_paused='' +airflow_cleanup_run_id='' + +kafka_cli() { + timeout 30s docker run --rm --network host "$kafka_image" \ + /opt/kafka/bin/kafka-topics.sh --bootstrap-server "localhost:${kafka_port}" "$@" +} + +kafka_topic_exists() { + local list + local listed_topic + + list="$(kafka_cli --list 2>/dev/null)" || return 2 + while IFS= read -r listed_topic; do + [[ "$listed_topic" == "$kafka_cleanup_topic" ]] && return 0 + done <<<"$list" + return 1 +} + +cleanup_kafka_topic() { + local attempt + local topic_status + + [[ -n "$kafka_cleanup_topic" ]] || return 0 + kafka_cli --delete --if-exists --topic "$kafka_cleanup_topic" >/dev/null 2>&1 || true + for ((attempt = 1; attempt <= 10; attempt++)); do + kafka_topic_exists + topic_status=$? + if [[ "$topic_status" -eq 1 ]]; then + kafka_cleanup_topic='' + return 0 + fi + sleep 1 + done + return 1 +} + +cleanup_airflow_run() { + local encoded_run_id + + [[ -n "$airflow_cleanup_run_id" ]] || return 0 + encoded_run_id="$(jq -rn --arg value "$airflow_cleanup_run_id" '$value | @uri')" + if curl -sf --max-time 10 -X DELETE \ + -H "Authorization: Bearer ${airflow_token}" \ + "http://127.0.0.1:${airflow_port}/api/v2/dags/${airflow_cleanup_dag_id}/dagRuns/${encoded_run_id}" \ + >/dev/null 2>&1; then + airflow_cleanup_run_id='' + return 0 + fi + return 1 +} + +cleanup_airflow_pause() { + local payload + + [[ -n "$airflow_cleanup_original_paused" ]] || return 0 + payload="$(jq -cn --argjson is_paused "$airflow_cleanup_original_paused" \ + '{is_paused: $is_paused}')" + if curl -sf --max-time 10 -X PATCH \ + -H "Authorization: Bearer ${airflow_token}" \ + -H 'Content-Type: application/json' \ + -d "$payload" \ + "http://127.0.0.1:${airflow_port}/api/v2/dags/${airflow_cleanup_dag_id}" \ + >/dev/null 2>&1; then + airflow_cleanup_original_paused='' + return 0 + fi + return 1 +} + +on_exit() { + local status=$? + trap - EXIT + cleanup_kafka_topic || true + cleanup_airflow_run || true + cleanup_airflow_pause || true + exit "$status" +} + +on_signal() { + trap - EXIT INT TERM + cleanup_kafka_topic || true + cleanup_airflow_run || true + cleanup_airflow_pause || true + exit 130 +} + +trap on_exit EXIT +trap on_signal INT TERM + +check_kafka_from_host() { + local attempt + local deleted=0 + local topic_status + + kafka_image="$(compose config --format json | jq -r '.services.kafka.image')" + kafka_port="$(published_port kafka 29092)" + kafka_cleanup_topic="check_services_${EPOCHSECONDS}_$$_${RANDOM}" + + if [[ -n "$kafka_port" ]] && \ + kafka_cli --create --topic "$kafka_cleanup_topic" --partitions 1 --replication-factor 1 >/dev/null 2>&1 && \ + kafka_topic_exists && \ + kafka_cli --delete --topic "$kafka_cleanup_topic" >/dev/null 2>&1; then + for ((attempt = 1; attempt <= 10; attempt++)); do + kafka_topic_exists + topic_status=$? + if [[ "$topic_status" -eq 1 ]]; then + deleted=1 + kafka_cleanup_topic='' + break + fi + sleep 1 + done + fi + + if [[ "$deleted" -eq 1 ]]; then + pass "Kafka доступна с машины через localhost:${kafka_port}: временный топик создан, найден и удалён" + return + fi + cleanup_kafka_topic || true + fail "Kafka недоступна с машины через отображённый порт ${kafka_port:-не найден} или временный топик не исчез после удаления" +} + +# Вход в Airflow здесь не проверка, а подготовка: то же самое утверждает make +# smoke, и там оно засчитано. Отдельного ЗЕЛЁНО на этот вход не заводим. +airflow_sign_in() { + local config + local password + local response + local user + + config="$(compose config --format json 2>/dev/null || true)" + user="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_USER // empty' <<<"$config")" + password="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_PASSWORD // empty' <<<"$config")" + airflow_port="$(published_port airflow-apiserver 8080)" + + response="$(curl -sf --max-time 10 -X POST \ + -H 'Content-Type: application/json' \ + -d "$(jq -cn --arg username "$user" --arg password "$password" \ + '{username: $username, password: $password}')" \ + "http://127.0.0.1:${airflow_port}/auth/token" 2>/dev/null || true)" + airflow_token="$(jq -r '.access_token // empty' <<<"$response")" + [[ -n "$airflow_token" ]] +} + +run_airflow_probe() { + local dag + local dag_id="$1" + local encoded_run_id + local response + local run_state='' + local unpaused + local -i attempt + + dag="$(curl -sf --max-time 10 \ + -H "Authorization: Bearer ${airflow_token}" \ + "http://127.0.0.1:${airflow_port}/api/v2/dags/${dag_id}" \ + 2>/dev/null || true)" + if ! jq -e --arg dag_id "$dag_id" '.dag_id == $dag_id' \ + >/dev/null 2>&1 <<<"$dag"; then + fail "пробник ${dag_id} не виден через API Airflow" + return 1 + fi + + airflow_cleanup_dag_id="$dag_id" + airflow_cleanup_original_paused="$(jq -r '.is_paused | tostring' <<<"$dag")" + unpaused="$(curl -sf --max-time 10 -X PATCH \ + -H "Authorization: Bearer ${airflow_token}" \ + -H 'Content-Type: application/json' \ + -d '{"is_paused":false}' \ + "http://127.0.0.1:${airflow_port}/api/v2/dags/${dag_id}" \ + 2>/dev/null || true)" + if ! jq -e '.is_paused == false' >/dev/null 2>&1 <<<"$unpaused"; then + fail "Airflow не смог включить пробник ${dag_id} перед ручным запуском" + cleanup_airflow_pause || true + return 1 + fi + + response="$(curl -sf --max-time 10 -X POST \ + -H "Authorization: Bearer ${airflow_token}" \ + -H 'Content-Type: application/json' \ + -d '{"logical_date":null}' \ + "http://127.0.0.1:${airflow_port}/api/v2/dags/${dag_id}/dagRuns" \ + 2>/dev/null || true)" + airflow_cleanup_run_id="$(jq -r '.dag_run_id // empty' <<<"$response")" + if [[ -z "$airflow_cleanup_run_id" ]]; then + fail "Airflow не создал ручной запуск пробника ${dag_id}" + cleanup_airflow_pause || true + return 1 + fi + + encoded_run_id="$(jq -rn --arg value "$airflow_cleanup_run_id" '$value | @uri')" + for ((attempt = 1; attempt <= 60; attempt++)); do + response="$(curl -sf --max-time 10 \ + -H "Authorization: Bearer ${airflow_token}" \ + "http://127.0.0.1:${airflow_port}/api/v2/dags/${dag_id}/dagRuns/${encoded_run_id}" \ + 2>/dev/null || true)" + run_state="$(jq -r '.state // empty' <<<"$response")" + [[ "$run_state" == 'success' || "$run_state" == 'failed' ]] && break + sleep 2 + done + + if [[ "$run_state" == 'success' ]] && \ + cleanup_airflow_run && cleanup_airflow_pause; then + pass "пробник ${dag_id} завершился успешно, запуск удалён, исходная пауза восстановлена" + return 0 + fi + + fail "пробник ${dag_id} не завершился чисто: состояние ${run_state:-неизвестно}" + cleanup_airflow_run || true + cleanup_airflow_pause || true + return 1 +} + +check_airflow_probes() { + if ! airflow_sign_in; then + fail 'Airflow не принял учётные данные администратора, пробники не запускались' + return + fi + run_airflow_probe test_clickhouse || true + run_airflow_probe test_kafka || true +} + +check_superset() { + local config + local login + local metadata_tables + local password + local port + local metadata_engine + local stored_config + local stored_uri + local stored_uuid + local superset_password + local superset_user + local user + + config="$(compose config --format json 2>/dev/null || true)" + user="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_USER // empty' <<<"$config")" + password="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_PASSWORD // empty' <<<"$config")" + superset_user="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_USER // empty' <<<"$config")" + superset_password="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_PASSWORD // empty' <<<"$config")" + port="$(published_port superset 8088)" + + login="$(curl -sf --max-time 10 -X POST \ + -H 'Content-Type: application/json' \ + -d "$(jq -cn --arg username "$user" --arg password "$password" \ + '{username: $username, password: $password, provider: "db", refresh: true}')" \ + "http://127.0.0.1:${port}/api/v1/security/login" 2>/dev/null || true)" + if curl -sf --max-time 10 "http://127.0.0.1:${port}/health" >/dev/null 2>&1 && \ + jq -e '.access_token | length > 0' >/dev/null 2>&1 <<<"$login"; then + pass 'интерфейс Superset отвечает и принимает подготовленные учётные данные администратора' + else + fail 'интерфейс Superset не отвечает или не принимает учётные данные администратора' + fi + + metadata_tables="$(timeout 20s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \ + exec -T -e "PGPASSWORD=${superset_password}" postgres-metadata \ + psql -U "$superset_user" -d superset -tAc \ + "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ab_user';" \ + 2>/dev/null || true)" + stored_config="$(timeout 30s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \ + exec -T superset python -c \ + "from superset.app import create_app; app = create_app(); app.app_context().push(); from superset.extensions import db; from superset.models.core import Database; url = db.engine.url; database = db.session.query(Database).filter_by(database_name='ClickHouse').one(); print('SMOKE_METADATA=' + '|'.join([url.get_backend_name(), url.username or '', url.host or '', str(url.port or ''), url.database or ''])); print('SMOKE_UUID=' + str(database.uuid)); print('SMOKE_URI=' + database.sqlalchemy_uri_decrypted)" \ + 2>/dev/null)" + metadata_engine="$(sed -n 's/^SMOKE_METADATA=//p' <<<"$stored_config" | tail -n 1)" + stored_uuid="$(sed -n 's/^SMOKE_UUID=//p' <<<"$stored_config" | tail -n 1)" + stored_uri="$(sed -n 's/^SMOKE_URI=//p' <<<"$stored_config" | tail -n 1)" + if [[ "$metadata_tables" == '1' ]] && \ + [[ "$metadata_engine" == "postgresql|${superset_user}|postgres-metadata|5432|superset" ]] && \ + [[ "$stored_uuid" == '4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d' ]] && \ + [[ "$stored_uri" == 'clickhousedb://default@clickhouse-02:8123/default' ]]; then + pass 'метаданные Superset живут в Postgres, подготовленное подключение указывает на clickhouse-02' + else + fail "Superset не подтвердил Postgres и подготовленное подключение к ноде 2: таблицы=${metadata_tables:-нет}, движок=${metadata_engine:-нет}, UUID=${stored_uuid:-нет}, URI=${stored_uri:-нет}" + return + fi + + if printf 'n\n' | timeout 60s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \ + exec -T superset superset test-db "$stored_uri" >/dev/null 2>&1; then + pass 'Superset успешно проверил извлечённое из метаданных подключение ClickHouse' + else + fail 'Superset не смог проверить извлечённое из метаданных подключение ClickHouse' + fi +} + +# Зависимости машины здесь не проверка, а условие запуска: «на машине есть +# Docker и curl» — утверждение о собранном стенде, и считает его make smoke. +if require_host_dependencies; then + check_kafka_from_host + check_airflow_probes + check_superset + check_containers_survived +else + fail 'проверки Kafka, Airflow и Superset пропущены без зависимостей машины' +fi + +print_total +[[ "$failed" -eq 0 ]] diff --git a/scripts/stand-smoke.sh b/scripts/stand-smoke.sh index 355689e..1570536 100755 --- a/scripts/stand-smoke.sh +++ b/scripts/stand-smoke.sh @@ -1,60 +1,11 @@ #!/usr/bin/env bash +# make smoke — стенд собран: службы живы, порты отвечают, подключения настроены +# друг на друга. Проверка идёт вширь и по касательной к каждой службе, ждать +# службу здесь не полагается. Карта целей и правило быстрого смоука — +# docs/architecture/testing.md. set -uo pipefail -readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -read -r -a COMPOSE_CMD <<<"${COMPOSE_BIN:-docker compose}" -readonly -a LONG_LIVED_SERVICES=( - clickhouse-keeper clickhouse-01 clickhouse-02 kafka postgres-metadata - airflow-apiserver airflow-scheduler airflow-dag-processor - superset prometheus grafana -) - -passed=0 -failed=0 -kafka_cleanup_image='' -kafka_cleanup_port='' -kafka_cleanup_topic='' -airflow_cleanup_port='' -airflow_cleanup_dag_id='' -airflow_cleanup_original_paused='' -airflow_cleanup_run_id='' -airflow_cleanup_token='' - -compose() { - "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" "$@" -} - -pass() { - passed=$((passed + 1)) - printf 'ЗЕЛЁНО: %s.\n' "$1" -} - -fail() { - failed=$((failed + 1)) - printf 'ОШИБКА: %s.\n' "$1" >&2 -} - -check_host_dependencies() { - local command - local -a missing=() - - for command in docker curl jq awk grep sed tail sleep timeout; do - command -v "$command" >/dev/null 2>&1 || missing+=("$command") - done - if [[ "${#missing[@]}" -gt 0 ]]; then - fail "на машине не хватает команд: ${missing[*]}" - return 1 - fi - if ! docker compose version >/dev/null 2>&1; then - fail 'Docker Compose недоступен; установите модуль compose для Docker' - return 1 - fi - if ! docker info >/dev/null 2>&1; then - fail 'Docker недоступен; запустите Docker и проверьте доступ к его сокету' - return 1 - fi - pass 'на машине доступны Docker, Docker Compose, curl, jq, awk, grep, sed, tail, sleep и timeout' -} +source "$(dirname "${BASH_SOURCE[0]}")/stand-common.sh" || exit 1 check_container_health() { local container_id @@ -102,134 +53,6 @@ check_keeper_runtime() { fi } -published_port() { - local binding - local service="$1" - local container_port="$2" - binding="$(compose port "$service" "$container_port" 2>/dev/null || true)" - printf '%s\n' "${binding##*:}" -} - -kafka_cli() { - timeout 30s docker run --rm --network host "$kafka_cleanup_image" \ - /opt/kafka/bin/kafka-topics.sh --bootstrap-server "localhost:${kafka_cleanup_port}" "$@" -} - -kafka_topic_exists() { - local list - local listed_topic - - list="$(kafka_cli --list 2>/dev/null)" || return 2 - while IFS= read -r listed_topic; do - [[ "$listed_topic" == "$kafka_cleanup_topic" ]] && return 0 - done <<<"$list" - return 1 -} - -cleanup_kafka_topic() { - local attempt - local topic_status - - [[ -n "$kafka_cleanup_topic" ]] || return 0 - kafka_cli --delete --if-exists --topic "$kafka_cleanup_topic" >/dev/null 2>&1 || true - for ((attempt = 1; attempt <= 10; attempt++)); do - kafka_topic_exists - topic_status=$? - if [[ "$topic_status" -eq 1 ]]; then - kafka_cleanup_topic='' - return 0 - fi - sleep 1 - done - return 1 -} - -cleanup_airflow_run() { - local encoded_run_id - - [[ -n "$airflow_cleanup_run_id" ]] || return 0 - encoded_run_id="$(jq -rn --arg value "$airflow_cleanup_run_id" '$value | @uri')" - if curl -sf --max-time 10 -X DELETE \ - -H "Authorization: Bearer ${airflow_cleanup_token}" \ - "http://127.0.0.1:${airflow_cleanup_port}/api/v2/dags/${airflow_cleanup_dag_id}/dagRuns/${encoded_run_id}" \ - >/dev/null 2>&1; then - airflow_cleanup_run_id='' - return 0 - fi - return 1 -} - -cleanup_airflow_pause() { - local payload - - [[ -n "$airflow_cleanup_original_paused" ]] || return 0 - payload="$(jq -cn --argjson is_paused "$airflow_cleanup_original_paused" \ - '{is_paused: $is_paused}')" - if curl -sf --max-time 10 -X PATCH \ - -H "Authorization: Bearer ${airflow_cleanup_token}" \ - -H 'Content-Type: application/json' \ - -d "$payload" \ - "http://127.0.0.1:${airflow_cleanup_port}/api/v2/dags/${airflow_cleanup_dag_id}" \ - >/dev/null 2>&1; then - airflow_cleanup_original_paused='' - return 0 - fi - return 1 -} - -on_exit() { - local status=$? - trap - EXIT - cleanup_kafka_topic || true - cleanup_airflow_run || true - cleanup_airflow_pause || true - exit "$status" -} - -on_signal() { - trap - EXIT INT TERM - cleanup_kafka_topic || true - cleanup_airflow_run || true - cleanup_airflow_pause || true - exit 130 -} - -trap on_exit EXIT -trap on_signal INT TERM - -check_kafka_from_host() { - local attempt - local deleted=0 - local topic_status - - kafka_cleanup_image="$(compose config --format json | jq -r '.services.kafka.image')" - kafka_cleanup_port="$(published_port kafka 29092)" - kafka_cleanup_topic="stand_smoke_${EPOCHSECONDS}_$$_${RANDOM}" - - if [[ -n "$kafka_cleanup_port" ]] && \ - kafka_cli --create --topic "$kafka_cleanup_topic" --partitions 1 --replication-factor 1 >/dev/null 2>&1 && \ - kafka_topic_exists && \ - kafka_cli --delete --topic "$kafka_cleanup_topic" >/dev/null 2>&1; then - for ((attempt = 1; attempt <= 10; attempt++)); do - kafka_topic_exists - topic_status=$? - if [[ "$topic_status" -eq 1 ]]; then - deleted=1 - kafka_cleanup_topic='' - break - fi - sleep 1 - done - fi - - if [[ "$deleted" -eq 1 ]]; then - pass "Kafka доступна с машины через localhost:${kafka_cleanup_port}: временный топик создан, найден и удалён" - return - fi - cleanup_kafka_topic || true - fail "Kafka недоступна с машины через отображённый порт ${kafka_cleanup_port:-не найден} или временный топик не исчез после удаления" -} - check_prometheus_targets() { local port local response @@ -302,76 +125,11 @@ check_grafana_datasource() { fi } -run_airflow_probe() { - local dag - local dag_id="$1" - local encoded_run_id - local response - local run_state='' - local unpaused - local -i attempt - - dag="$(curl -sf --max-time 10 \ - -H "Authorization: Bearer ${airflow_cleanup_token}" \ - "http://127.0.0.1:${airflow_cleanup_port}/api/v2/dags/${dag_id}" \ - 2>/dev/null || true)" - if ! jq -e --arg dag_id "$dag_id" '.dag_id == $dag_id' \ - >/dev/null 2>&1 <<<"$dag"; then - fail "пробник ${dag_id} не виден через API Airflow" - return 1 - fi - - airflow_cleanup_dag_id="$dag_id" - airflow_cleanup_original_paused="$(jq -r '.is_paused | tostring' <<<"$dag")" - unpaused="$(curl -sf --max-time 10 -X PATCH \ - -H "Authorization: Bearer ${airflow_cleanup_token}" \ - -H 'Content-Type: application/json' \ - -d '{"is_paused":false}' \ - "http://127.0.0.1:${airflow_cleanup_port}/api/v2/dags/${dag_id}" \ - 2>/dev/null || true)" - if ! jq -e '.is_paused == false' >/dev/null 2>&1 <<<"$unpaused"; then - fail "Airflow не смог включить пробник ${dag_id} перед ручным запуском" - cleanup_airflow_pause || true - return 1 - fi - - response="$(curl -sf --max-time 10 -X POST \ - -H "Authorization: Bearer ${airflow_cleanup_token}" \ - -H 'Content-Type: application/json' \ - -d '{"logical_date":null}' \ - "http://127.0.0.1:${airflow_cleanup_port}/api/v2/dags/${dag_id}/dagRuns" \ - 2>/dev/null || true)" - airflow_cleanup_run_id="$(jq -r '.dag_run_id // empty' <<<"$response")" - if [[ -z "$airflow_cleanup_run_id" ]]; then - fail "Airflow не создал ручной запуск пробника ${dag_id}" - cleanup_airflow_pause || true - return 1 - fi - - encoded_run_id="$(jq -rn --arg value "$airflow_cleanup_run_id" '$value | @uri')" - for ((attempt = 1; attempt <= 60; attempt++)); do - response="$(curl -sf --max-time 10 \ - -H "Authorization: Bearer ${airflow_cleanup_token}" \ - "http://127.0.0.1:${airflow_cleanup_port}/api/v2/dags/${dag_id}/dagRuns/${encoded_run_id}" \ - 2>/dev/null || true)" - run_state="$(jq -r '.state // empty' <<<"$response")" - [[ "$run_state" == 'success' || "$run_state" == 'failed' ]] && break - sleep 2 - done - - if [[ "$run_state" == 'success' ]] && \ - cleanup_airflow_run && cleanup_airflow_pause; then - pass "пробник ${dag_id} завершился успешно, запуск удалён, исходная пауза восстановлена" - return 0 - fi - - fail "пробник ${dag_id} не завершился чисто: состояние ${run_state:-неизвестно}" - cleanup_airflow_run || true - cleanup_airflow_pause || true - return 1 -} - +# Три вопроса о связности, и все три Airflow отвечает сразу. Запуск пробников — +# другое дело: он ждёт такта планировщика и живёт в make check-services. check_airflow() { + local airflow_port + local airflow_token local config local connection local dag @@ -384,10 +142,10 @@ check_airflow() { config="$(compose config --format json 2>/dev/null || true)" user="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_USER // empty' <<<"$config")" password="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_PASSWORD // empty' <<<"$config")" - airflow_cleanup_port="$(published_port airflow-apiserver 8080)" + airflow_port="$(published_port airflow-apiserver 8080)" health="$(curl -sf --max-time 10 \ - "http://127.0.0.1:${airflow_cleanup_port}/api/v2/monitor/health" 2>/dev/null || true)" + "http://127.0.0.1:${airflow_port}/api/v2/monitor/health" 2>/dev/null || true)" if jq -e ' .metadatabase.status == "healthy" and .scheduler.status == "healthy" and @@ -402,16 +160,16 @@ check_airflow() { -H 'Content-Type: application/json' \ -d "$(jq -cn --arg username "$user" --arg password "$password" \ '{username: $username, password: $password}')" \ - "http://127.0.0.1:${airflow_cleanup_port}/auth/token" 2>/dev/null || true)" - airflow_cleanup_token="$(jq -r '.access_token // empty' <<<"$response")" - if [[ -z "$airflow_cleanup_token" ]]; then + "http://127.0.0.1:${airflow_port}/auth/token" 2>/dev/null || true)" + airflow_token="$(jq -r '.access_token // empty' <<<"$response")" + if [[ -z "$airflow_token" ]]; then fail 'Airflow не принял учётные данные администратора' return fi for dag_id in test_clickhouse test_kafka; do dag="$(curl -sf --max-time 10 \ - -H "Authorization: Bearer ${airflow_cleanup_token}" \ - "http://127.0.0.1:${airflow_cleanup_port}/api/v2/dags/${dag_id}" \ + -H "Authorization: Bearer ${airflow_token}" \ + "http://127.0.0.1:${airflow_port}/api/v2/dags/${dag_id}" \ 2>/dev/null || true)" if ! jq -e --arg dag_id "$dag_id" '.dag_id == $dag_id' \ >/dev/null 2>&1 <<<"$dag"; then @@ -422,8 +180,8 @@ check_airflow() { pass 'учётные данные администратора Airflow принимаются, оба пробника видны через API' connection="$(curl -sf --max-time 10 \ - -H "Authorization: Bearer ${airflow_cleanup_token}" \ - "http://127.0.0.1:${airflow_cleanup_port}/api/v2/connections/clickhouse_default" \ + -H "Authorization: Bearer ${airflow_token}" \ + "http://127.0.0.1:${airflow_port}/api/v2/connections/clickhouse_default" \ 2>/dev/null || true)" if jq -e ' .connection_id == "clickhouse_default" and @@ -440,125 +198,21 @@ check_airflow() { else fail 'подключение Airflow не указывает на clickhouse-01 или нода недоступна из контейнера' fi - - run_airflow_probe test_clickhouse || true - run_airflow_probe test_kafka || true } -check_superset() { - local config - local login - local metadata_tables - local password - local port - local metadata_engine - local stored_config - local stored_uri - local stored_uuid - local superset_password - local superset_user - local user - - config="$(compose config --format json 2>/dev/null || true)" - user="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_USER // empty' <<<"$config")" - password="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_PASSWORD // empty' <<<"$config")" - superset_user="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_USER // empty' <<<"$config")" - superset_password="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_PASSWORD // empty' <<<"$config")" - port="$(published_port superset 8088)" - - login="$(curl -sf --max-time 10 -X POST \ - -H 'Content-Type: application/json' \ - -d "$(jq -cn --arg username "$user" --arg password "$password" \ - '{username: $username, password: $password, provider: "db", refresh: true}')" \ - "http://127.0.0.1:${port}/api/v1/security/login" 2>/dev/null || true)" - if curl -sf --max-time 10 "http://127.0.0.1:${port}/health" >/dev/null 2>&1 && \ - jq -e '.access_token | length > 0' >/dev/null 2>&1 <<<"$login"; then - pass 'интерфейс Superset отвечает и принимает подготовленные учётные данные администратора' - else - fail 'интерфейс Superset не отвечает или не принимает учётные данные администратора' - fi - - metadata_tables="$(timeout 20s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \ - exec -T -e "PGPASSWORD=${superset_password}" postgres-metadata \ - psql -U "$superset_user" -d superset -tAc \ - "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ab_user';" \ - 2>/dev/null || true)" - stored_config="$(timeout 30s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \ - exec -T superset python -c \ - "from superset.app import create_app; app = create_app(); app.app_context().push(); from superset.extensions import db; from superset.models.core import Database; url = db.engine.url; database = db.session.query(Database).filter_by(database_name='ClickHouse').one(); print('SMOKE_METADATA=' + '|'.join([url.get_backend_name(), url.username or '', url.host or '', str(url.port or ''), url.database or ''])); print('SMOKE_UUID=' + str(database.uuid)); print('SMOKE_URI=' + database.sqlalchemy_uri_decrypted)" \ - 2>/dev/null)" - metadata_engine="$(sed -n 's/^SMOKE_METADATA=//p' <<<"$stored_config" | tail -n 1)" - stored_uuid="$(sed -n 's/^SMOKE_UUID=//p' <<<"$stored_config" | tail -n 1)" - stored_uri="$(sed -n 's/^SMOKE_URI=//p' <<<"$stored_config" | tail -n 1)" - if [[ "$metadata_tables" == '1' ]] && \ - [[ "$metadata_engine" == "postgresql|${superset_user}|postgres-metadata|5432|superset" ]] && \ - [[ "$stored_uuid" == '4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d' ]] && \ - [[ "$stored_uri" == 'clickhousedb://default@clickhouse-02:8123/default' ]]; then - pass 'метаданные Superset живут в Postgres, подготовленное подключение указывает на clickhouse-02' - else - fail "Superset не подтвердил Postgres и подготовленное подключение к ноде 2: таблицы=${metadata_tables:-нет}, движок=${metadata_engine:-нет}, UUID=${stored_uuid:-нет}, URI=${stored_uri:-нет}" - return - fi - - if printf 'n\n' | timeout 60s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \ - exec -T superset superset test-db "$stored_uri" >/dev/null 2>&1; then - pass 'Superset успешно проверил извлечённое из метаданных подключение ClickHouse' - else - fail 'Superset не смог проверить извлечённое из метаданных подключение ClickHouse' - fi -} - -# Убитый за память контейнер Docker поднимает сам, и проверка здоровья об этом -# промолчит. Порога здесь нет: это «да или нет», а не бюджет памяти (ADR 0004). -check_containers_survived() { - local container_id - local service - local state - local problems=0 - - for service in "${LONG_LIVED_SERVICES[@]}"; do - container_id="$(compose ps --all --quiet "$service" 2>/dev/null || true)" - if [[ -z "$container_id" || "$container_id" == *$'\n'* ]]; then - fail "не удалось получить контейнер ${service} для проверки перезапусков" - return - fi - state="$(docker inspect --format '{{.State.OOMKilled}}/{{.RestartCount}}' "$container_id" 2>/dev/null || true)" - case "$state" in - false/0) ;; - # OOMKilled встаёт и когда убит процесс внутри живого контейнера. - true/*) - fail "в контейнере ${service} ядро убило процесс из-за нехватки памяти" - problems=$((problems + 1)) - ;; - false/*) - fail "контейнер ${service} перезапускался, счётчик Docker — ${state#*/}" - problems=$((problems + 1)) - ;; - *) - fail "Docker не рассказал о состоянии контейнера ${service}" - problems=$((problems + 1)) - ;; - esac - done - if [[ "$problems" -eq 0 ]]; then - pass 'ни в одном долгоживущем контейнере ядро не убивало процессы за память, и никто не перезапускался сам' - fi -} - -if check_host_dependencies; then +if require_host_dependencies; then + pass 'на машине доступны Docker, Docker Compose, curl, jq, awk, grep, sed, tail, sleep и timeout' for service in "${LONG_LIVED_SERVICES[@]}"; do check_container_health "$service" done check_keeper_runtime - check_kafka_from_host check_prometheus_targets check_grafana_datasource check_airflow - check_superset check_containers_survived else - fail 'проверки контейнеров, Kafka, Airflow, Superset, Prometheus и Grafana пропущены без зависимостей машины' + fail 'проверки контейнеров, Airflow, Prometheus и Grafana пропущены без зависимостей машины' fi -printf 'ИТОГ: пройдено %d, ошибок %d\n' "$passed" "$failed" +print_total [[ "$failed" -eq 0 ]]