refactor(smoke): цели проверки по назначению — смоук, ClickHouse, службы

- Зачем:
  - смоук перестал быть быстрым: 42 секунды из 48 съедали шесть проверок,
    которые ждут службу — запуск DAG, вход в Superset, Kafka с машины.
  - имена целей врали: смоуком звались и глубокая проверка кластера, и
    интеграционные проверки; префикс достался им от общего происхождения.
  - нигде не было записано, зачем в репозитории каждая цель и куда класть
    новую проверку, — без записи скрипт дорастёт снова.
- Что:
  - ось деления — кого спрашивают, а не сколько стоит: make smoke (стенд
    собран), make check-clickhouse (спрашивают у ClickHouse), новая
    make check-services (службы работают).
  - шесть тяжёлых проверок переехали в scripts/stand-services.sh; общее —
    счёт, обращение к Compose, зависимости машины и check_containers_survived
    — вынесено в scripts/stand-common.sh, копипасты нет.
  - smoke-cluster переименована в check-clickhouse; имя файла скрипта не
    тронуто (в него встраивается проверка договора со схемой), расхождение
    названо в карте.
  - смоук и check-services печатают своё время в строке ИТОГ; порога по
    времени нет — по доводу ADR 0004.
  - docs/architecture/testing.md: карта всех семи целей, правило быстрого
    смоука словами, лесенка по частоте и правило про краснеющую проверку,
    переехавшее из README; указатель из AGENTS.md.
  - README: описания целей сокращены, карта не дублируется; быстрый старт
    показывает работающий стенд, а не только собранный.
  - планка приёмки этапа в спеке названа поимённо: три цели вместо
    «smoke-проверки».
- Проверка:
  - make config-test, make smoke (19 проверок, 6 с), make check-clickhouse
    (8 проверок, 7 с), make check-services (7 проверок, 44 с) — зелёные.
  - 19 + 7 = 25 разных проверок, как и до деления: check_containers_survived
    считается дважды намеренно.
  - краснеют обе разделённые цели: со снятым prometheus смоук дал три ошибки,
    с подменённым UUID подключения Superset покраснел check-services.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 14:34:02 +03:00
co-authored by Claude Opus 5
parent f84f8c416f
commit bbbe17cfb0
9 changed files with 638 additions and 412 deletions
+2
View File
@@ -59,6 +59,8 @@ Airflow) и названия из кода. Если для понятия ес
- Python — только через `uv`; проверка и формат — `ruff` (`make lint`). - Python — только через `uv`; проверка и формат — `ruff` (`make lint`).
- Изменения держать минимальными и в границах задания. - Изменения держать минимальными и в границах задания.
- Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук
обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md).
- Секреты не коммитить: настройки — через `.env`, образец — `.env.example`. - Секреты не коммитить: настройки — через `.env`, образец — `.env.example`.
- При изменении инфраструктуры или DDL обновлять документацию тем же PR. - При изменении инфраструктуры или DDL обновлять документацию тем же PR.
- Коммиты — Conventional Commits: заголовок `type(scope): результат`, тело на - Коммиты — Conventional Commits: заголовок `type(scope): результат`, тело на
+5 -2
View File
@@ -1,6 +1,6 @@
COMPOSE ?= docker compose 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: up:
$(COMPOSE) up --detach --build --wait --wait-timeout 600 $(COMPOSE) up --detach --build --wait --wait-timeout 600
@@ -36,5 +36,8 @@ docs:
smoke: smoke:
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh
smoke-cluster: check-clickhouse:
./scripts/clickhouse-smoke.sh ./scripts/clickhouse-smoke.sh
check-services:
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-services.sh
+30 -38
View File
@@ -23,7 +23,7 @@ Prometheus, Grafana и общая база Postgres для метаданных.
пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не
хватает, стенд одинаково хорошо живёт на недорогом VPS. хватает, стенд одинаково хорошо живёт на недорогом VPS.
Полная проверка также использует `curl`, `jq`, `awk`, Проверкам на поднятом стенде также нужны `curl`, `jq`, `awk`,
`grep`, `sed`, `tail`, `sleep` и `timeout`. По умолчанию должны быть свободны `grep`, `sed`, `tail`, `sleep` и `timeout`. По умолчанию должны быть свободны
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090` порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
и `29092`. Проверкам без стенда — `make config-test`, `make lint`, и `29092`. Проверкам без стенда — `make config-test`, `make lint`,
@@ -34,8 +34,15 @@ Prometheus, Grafana и общая база Postgres для метаданных.
```bash ```bash
make up make up
make smoke make smoke
make check-clickhouse
make check-services
``` ```
Первому знакомству нужны все три проверки на стенде: `make smoke` говорит, что
стенд собран, `make check-clickhouse` — что кластер работает кластером, а
`make check-services` — что Airflow запускает DAG, а Superset ходит в базу.
Дальше, в рабочей петле, обычно хватает `make smoke`.
Чтобы изменить образы, порты или учебные учётные данные, скопируйте образец: Чтобы изменить образы, порты или учебные учётные данные, скопируйте образец:
```bash ```bash
@@ -62,17 +69,12 @@ make smoke
подготавливает администратора и подключение к `clickhouse-01`. Второй обновляет подготавливает администратора и подключение к `clickhouse-01`. Второй обновляет
Superset, создаёт администратора и импортирует подключение к `clickhouse-02`. Superset, создаёт администратора и импортирует подключение к `clickhouse-02`.
`make smoke` проверяет зависимости машины, здоровье контейнеров, устройство `make smoke` за секунды спрашивает, собран ли стенд: зависимости машины,
keeper, Kafka через порт машины, три цели Prometheus, источник Grafana, здоровье контейнеров, устройство keeper, три цели Prometheus, источник Grafana,
компоненты Airflow, ручной запуск пробников `test_clickhouse` и `test_kafka`, компоненты Airflow и подготовленное подключение к `clickhouse-01`. В конце
метаданные и подключение Superset. Первый пробник создаёт
таблицы на обеих нодах и читает через `Distributed` на ноде 2 строку из
локальной таблицы ноды 1. Второй пишет в Kafka и читает свой маркер. В конце
проверка спрашивает у Docker, не убивало ли ядро что-нибудь в долгоживущих проверка спрашивает у Docker, не убивало ли ядро что-нибудь в долгоживущих
контейнерах за нехватку памяти и не включалась ли политика перезапуска: убитый контейнерах за нехватку памяти и не включалась ли политика перезапуска: убитый
контейнер Docker поднимает сам, и проверка состояния об этом промолчит. контейнер Docker поднимает сам, и проверка состояния об этом промолчит.
Временный топик проверки с машины и запуски DAG удаляются;
постоянный топик пробника сохраняется, а старые записи чистит Kafka.
Одиннадцать проверок здоровья сразу после `make up --wait` повторяют то, чего Одиннадцать проверок здоровья сразу после `make up --wait` повторяют то, чего
Compose уже дождался: у каждой долгоживущей службы есть своя `healthcheck`. Compose уже дождался: у каждой долгоживущей службы есть своя `healthcheck`.
@@ -83,37 +85,25 @@ Compose уже дождался: у каждой долгоживущей слу
выглядит и без этого, поэтому смоук спрашивает у живого контейнера, дошли ли выглядит и без этого, поэтому смоук спрашивает у живого контейнера, дошли ли
объявленные настройки до процесса. объявленные настройки до процесса.
`make smoke-cluster` запускает отдельную глубокую проверку ClickHouse: описание `make check-services` проверяет то, ради чего приходится ждать службу: Kafka
кластера, макросы, связь с keeper, `ReplicatedMergeTree`, `Distributed`, очередь через порт машины, ручной запуск пробников `test_clickhouse` и `test_kafka`,
распределённых DDL и очистку временных таблиц. вход в Superset, его метаданные и подключение к `clickhouse-02`. Первый пробник
создаёт таблицы на обеих нодах и читает через `Distributed` на ноде 2 строку из
локальной таблицы ноды 1. Второй пишет в Kafka и читает свой маркер. Временный
топик проверки с машины и запуски DAG удаляются; постоянный топик пробника
сохраняется, а старые записи чистит Kafka.
`make check-clickhouse` запускает отдельную глубокую проверку ClickHouse:
описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`,
`Distributed`, очередь распределённых DDL и очистку временных таблиц.
`make config-test` проверяет Compose, синтаксис Bash и Python и пробельные `make config-test` проверяет Compose, синтаксис Bash и Python и пробельные
ошибки в diff без запуска стенда. ошибки в diff без запуска стенда.
Правило, которое стоит держать в голове, правя любую из этих проверок: Обычный рабочий цикл — `make config-test` и `make smoke`; остальные цели гоняют
**проверка, которая не умеет краснеть, бесполезна.** Проверка, никогда не тогда, когда правка их касается. Что утверждает каждая проверка, нужен ли ей
видевшая своей поломки, доказывает только то, что она умеет печатать «ЗЕЛЁНО». поднятый стенд, сколько стоит прогон и куда класть новую — в
Убедиться дешевле всего руками: сломайте то, что она стережёт — остановите [карте проверок](docs/architecture/testing.md).
`prometheus`, удалите служебную таблицу пробника на второй ноде, — и посмотрите,
покраснеет ли прогон и назовёт ли виновника. Не покраснел — проверка не
работает, и чинить надо её, а не стенд.
### Какую проверку когда запускать
Проверки выстроены лесенкой: чем дороже прогон, тем больше связей он трогает.
- `make config-test` — секунды, стенд поднимать не нужно. Видит только то, что
есть в файлах, и о работоспособности не говорит ничего. Дёшево настолько, что
можно гонять перед каждым коммитом.
- `make lint` и `make test` — тоже секунды и тоже без стенда, но про другой
код: ruff и тесты генератора в `generator/`. Тесты сторожат контракт схемы
события и свежесть собранного из него описания выгрузки.
- `make smoke` — минута-две на поднятом стенде. Дороже, но проверяет связи
между службами, а не отдельные файлы: это интеграционная проверка.
- `make smoke-cluster` — около минуты. Одна связь, зато до дна: межнодовое
устройство ClickHouse.
Обычный рабочий цикл — `make config-test` и `make smoke`.
Остановить контейнеры без удаления данных можно командой `make down`. Для Остановить контейнеры без удаления данных можно командой `make down`. Для
полного сброса с удалением всех именованных томов используйте `make clean`. полного сброса с удалением всех именованных томов используйте `make clean`.
@@ -218,8 +208,10 @@ Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, ф
- [docs/adr/](docs/adr/) — принятые решения с доводами и отвергнутыми - [docs/adr/](docs/adr/) — принятые решения с доводами и отвергнутыми
вариантами: почему сделано так, а не иначе. вариантами: почему сделано так, а не иначе.
- [docs/architecture/](docs/architecture/) — рабочие справочники по зонам - [docs/architecture/](docs/architecture/) — рабочие справочники по зонам
ответственности; сейчас это [хранилище](docs/architecture/storage.md): ответственности: [хранилище](docs/architecture/storage.md) — конвенции имён,
конвенции имён, раскладка по шардам, приём событий, карта таблиц. раскладка по шардам, приём событий, карта таблиц;
[проверки](docs/architecture/testing.md) — что утверждает каждая цель `make`
и куда класть новую проверку.
- [docs/research/](docs/research/) — исследования; сейчас это формат - [docs/research/](docs/research/) — исследования; сейчас это формат
кликстрима Яндекса, по которому строится модель события. кликстрима Яндекса, по которому строится модель события.
- [docs/formats/](docs/formats/) — описания форматов источников: по ним - [docs/formats/](docs/formats/) — описания форматов источников: по ним
+150
View File
@@ -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, раздел «Что проверено».
+5 -2
View File
@@ -547,8 +547,11 @@ v2 стартует пустым, поэтому объём ниже — это
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов 9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
и границы — ADR 0002. и границы — ADR 0002.
Критерий приёмки этапа — честный: `make up` работает и проходят Критерий приёмки этапа — честный: `make up` работает и проходят все три
smoke-проверки, а не «дашборд зелёный». Это минимальная планка; свои проверки на стенде — `make smoke`, `make check-clickhouse` и
`make check-services`, — а не «дашборд зелёный». Названы они поимённо
намеренно: под общим словом «smoke-проверки» планка тихо опустилась бы при
следующем делении целей. Это минимальная планка; свои
наблюдаемые критерии каждый этап получает при разбиении в /to-tickets. Документация правится в PR этапа наблюдаемые критерии каждый этап получает при разбиении в /to-tickets. Документация правится в PR этапа
(правило AGENTS.md). (правило AGENTS.md).
+2 -2
View File
@@ -65,14 +65,14 @@ on_exit() {
if [[ "$status" -ne 0 ]]; then if [[ "$status" -ne 0 ]]; then
printf 'Сбой проверки: выполняется быстрая очистка временных таблиц...\n' >&2 printf 'Сбой проверки: выполняется быстрая очистка временных таблиц...\n' >&2
fi fi
cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make smoke-cluster.\n' >&2 cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make check-clickhouse.\n' >&2
exit "$status" exit "$status"
} }
on_signal() { on_signal() {
trap - EXIT INT TERM trap - EXIT INT TERM
printf 'Проверка прервана пользователем: выполняется быстрая очистка временных таблиц...\n' >&2 printf 'Проверка прервана пользователем: выполняется быстрая очистка временных таблиц...\n' >&2
cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make smoke-cluster.\n' >&2 cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make check-clickhouse.\n' >&2
exit 130 exit 130
} }
+108
View File
@@ -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"
}
+314
View File
@@ -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 ]]
+22 -368
View File
@@ -1,60 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# make smoke — стенд собран: службы живы, порты отвечают, подключения настроены
# друг на друга. Проверка идёт вширь и по касательной к каждой службе, ждать
# службу здесь не полагается. Карта целей и правило быстрого смоука —
# docs/architecture/testing.md.
set -uo pipefail set -uo pipefail
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "$(dirname "${BASH_SOURCE[0]}")/stand-common.sh" || exit 1
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'
}
check_container_health() { check_container_health() {
local container_id local container_id
@@ -102,134 +53,6 @@ check_keeper_runtime() {
fi 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() { check_prometheus_targets() {
local port local port
local response local response
@@ -302,76 +125,11 @@ check_grafana_datasource() {
fi fi
} }
run_airflow_probe() { # Три вопроса о связности, и все три Airflow отвечает сразу. Запуск пробников —
local dag # другое дело: он ждёт такта планировщика и живёт в make check-services.
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
}
check_airflow() { check_airflow() {
local airflow_port
local airflow_token
local config local config
local connection local connection
local dag local dag
@@ -384,10 +142,10 @@ check_airflow() {
config="$(compose config --format json 2>/dev/null || true)" config="$(compose config --format json 2>/dev/null || true)"
user="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_USER // empty' <<<"$config")" user="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_USER // empty' <<<"$config")"
password="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_PASSWORD // 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 \ 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 ' if jq -e '
.metadatabase.status == "healthy" and .metadatabase.status == "healthy" and
.scheduler.status == "healthy" and .scheduler.status == "healthy" and
@@ -402,16 +160,16 @@ check_airflow() {
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d "$(jq -cn --arg username "$user" --arg password "$password" \ -d "$(jq -cn --arg username "$user" --arg password "$password" \
'{username: $username, password: $password}')" \ '{username: $username, password: $password}')" \
"http://127.0.0.1:${airflow_cleanup_port}/auth/token" 2>/dev/null || true)" "http://127.0.0.1:${airflow_port}/auth/token" 2>/dev/null || true)"
airflow_cleanup_token="$(jq -r '.access_token // empty' <<<"$response")" airflow_token="$(jq -r '.access_token // empty' <<<"$response")"
if [[ -z "$airflow_cleanup_token" ]]; then if [[ -z "$airflow_token" ]]; then
fail 'Airflow не принял учётные данные администратора' fail 'Airflow не принял учётные данные администратора'
return return
fi fi
for dag_id in test_clickhouse test_kafka; do for dag_id in test_clickhouse test_kafka; do
dag="$(curl -sf --max-time 10 \ dag="$(curl -sf --max-time 10 \
-H "Authorization: Bearer ${airflow_cleanup_token}" \ -H "Authorization: Bearer ${airflow_token}" \
"http://127.0.0.1:${airflow_cleanup_port}/api/v2/dags/${dag_id}" \ "http://127.0.0.1:${airflow_port}/api/v2/dags/${dag_id}" \
2>/dev/null || true)" 2>/dev/null || true)"
if ! jq -e --arg dag_id "$dag_id" '.dag_id == $dag_id' \ if ! jq -e --arg dag_id "$dag_id" '.dag_id == $dag_id' \
>/dev/null 2>&1 <<<"$dag"; then >/dev/null 2>&1 <<<"$dag"; then
@@ -422,8 +180,8 @@ check_airflow() {
pass 'учётные данные администратора Airflow принимаются, оба пробника видны через API' pass 'учётные данные администратора Airflow принимаются, оба пробника видны через API'
connection="$(curl -sf --max-time 10 \ connection="$(curl -sf --max-time 10 \
-H "Authorization: Bearer ${airflow_cleanup_token}" \ -H "Authorization: Bearer ${airflow_token}" \
"http://127.0.0.1:${airflow_cleanup_port}/api/v2/connections/clickhouse_default" \ "http://127.0.0.1:${airflow_port}/api/v2/connections/clickhouse_default" \
2>/dev/null || true)" 2>/dev/null || true)"
if jq -e ' if jq -e '
.connection_id == "clickhouse_default" and .connection_id == "clickhouse_default" and
@@ -440,125 +198,21 @@ check_airflow() {
else else
fail 'подключение Airflow не указывает на clickhouse-01 или нода недоступна из контейнера' fail 'подключение Airflow не указывает на clickhouse-01 или нода недоступна из контейнера'
fi fi
run_airflow_probe test_clickhouse || true
run_airflow_probe test_kafka || true
} }
check_superset() { if require_host_dependencies; then
local config pass 'на машине доступны Docker, Docker Compose, curl, jq, awk, grep, sed, tail, sleep и timeout'
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
for service in "${LONG_LIVED_SERVICES[@]}"; do for service in "${LONG_LIVED_SERVICES[@]}"; do
check_container_health "$service" check_container_health "$service"
done done
check_keeper_runtime check_keeper_runtime
check_kafka_from_host
check_prometheus_targets check_prometheus_targets
check_grafana_datasource check_grafana_datasource
check_airflow check_airflow
check_superset
check_containers_survived check_containers_survived
else else
fail 'проверки контейнеров, Kafka, Airflow, Superset, Prometheus и Grafana пропущены без зависимостей машины' fail 'проверки контейнеров, Airflow, Prometheus и Grafana пропущены без зависимостей машины'
fi fi
printf 'ИТОГ: пройдено %d, ошибок %d\n' "$passed" "$failed" print_total
[[ "$failed" -eq 0 ]] [[ "$failed" -eq 0 ]]