feat(infra): стенд целиком — Kafka, Airflow 3, Superset и мониторинг одной командой

Зачем: рядом с кластером ClickHouse не хватало остальной платформы, а
поднимать её по кускам — значит каждый раз вспоминать порядок. Теперь
`make up` даёт стенд целиком, а `make smoke` честно отвечает, работает он
или нет.

Что:
- Kafka в режиме KRaft (без ZooKeeper), Postgres под метаданные, Airflow
  3.3 четырьмя сервисами и Superset 6.1 с драйверами ClickHouse;
- мониторинг: Prometheus снимает метрики с обеих нод ClickHouse и keeper,
  Grafana получает подготовленный источник данных;
- подключение Airflow ведёт на ноду 1, подключение Superset — на ноду 2:
  ловушка правильных ошибок, забытый `ON CLUSTER` виден в дашборде сам;
- `scripts/stand-smoke.sh` — сквозная проверка из 24 пунктов: топик в
  Kafka, цели Prometheus, запуск примера DAG через API Airflow, проверка
  подключения Superset и расход памяти против порога 3,4 ГБ;
- `make config-test` — статические ворота: Compose, синтаксис Bash и
  Python, стражи README; стражи smoke проверяют, что отчёт краснеет на
  сломанном стенде и зеленеет после восстановления;
- решения записаны в `docs/adr/0001-stand-services.md`, состав стенда и
  порядок работы — в README.

Проверка: на чистых томах `make clean` → `cp .env.example .env` →
`make up` (1 мин 51 с) → `make smoke` — 24 пройдено, 0 ошибок, 2171 MiB.
Перезапуск `make down` → `make up` → `make smoke` — 24/0. Также зелены
`make config-test` (3/0 и 5/0), `make smoke-cluster` (8/8) и
`make smoke-guards` (3/0 и 3/0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 08:46:04 +03:00
co-authored by Claude Opus 5
parent dca0142fd1
commit 63c42bc068
24 changed files with 1493 additions and 31 deletions
+27 -1
View File
@@ -4,4 +4,30 @@ CLICKHOUSE_01_HTTP_PORT=28123
CLICKHOUSE_01_TCP_PORT=29000
CLICKHOUSE_02_HTTP_PORT=28124
CLICKHOUSE_02_TCP_PORT=29001
KAFKA_IMAGE=apache/kafka:4.3.1
KAFKA_EXTERNAL_PORT=29092
PROMETHEUS_IMAGE=prom/prometheus:v3.13.2
PROMETHEUS_PORT=29090
GRAFANA_IMAGE=grafana/grafana:13.1.1
GRAFANA_PORT=23000
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin
POSTGRES_IMAGE=postgres:16-alpine
POSTGRES_ADMIN_USER=postgres
POSTGRES_ADMIN_PASSWORD=postgres-local
AIRFLOW_METADATA_USER=airflow
AIRFLOW_METADATA_PASSWORD=airflow-local
SUPERSET_METADATA_USER=superset
SUPERSET_METADATA_PASSWORD=superset-local
AIRFLOW_IMAGE=apache/airflow:3.3.0
AIRFLOW_PORT=28080
AIRFLOW_ADMIN_USER=admin
AIRFLOW_ADMIN_PASSWORD=airflow
AIRFLOW_JWT_SECRET=local-only-airflow-jwt-secret-do-not-use
AIRFLOW_API_SECRET_KEY=local-only-airflow-api-secret-do-not-use
AIRFLOW_FERNET_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
SUPERSET_IMAGE=apache/superset:6.1.0
SUPERSET_PORT=28088
SUPERSET_ADMIN_USER=admin
SUPERSET_ADMIN_PASSWORD=superset
SUPERSET_SECRET_KEY=local-only-superset-secret-do-not-use-outside-this-stand
+12 -3
View File
@@ -1,9 +1,9 @@
COMPOSE := docker compose
COMPOSE ?= docker compose
.PHONY: up down clean ps logs smoke smoke-guards
.PHONY: up down clean ps logs config-test smoke smoke-cluster smoke-guards
up:
$(COMPOSE) up --detach --wait --wait-timeout 180
$(COMPOSE) up --detach --build --wait --wait-timeout 600
down:
$(COMPOSE) down --remove-orphans
@@ -17,8 +17,17 @@ ps:
logs:
$(COMPOSE) logs --follow
config-test:
COMPOSE_BIN="$(COMPOSE)" ./scripts/config-test.sh
./tests/stand-smoke-static.sh
./tests/docs-guards.sh
smoke:
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh
smoke-cluster:
./scripts/clickhouse-smoke.sh
smoke-guards:
./tests/smoke-guards.sh
COMPOSE_BIN="$(COMPOSE)" ./tests/stand-smoke-guards.sh
+95 -23
View File
@@ -1,20 +1,31 @@
# Учебная дата-платформа кликстрима
Стенд для работы с кликстримом: Kafka, кластер ClickHouse (2 шарда и
clickhouse-keeper), Airflow, Superset. Преемник учебного стенда
Стенд для работы с кликстримом. Преемник учебного стенда
[clickstream-ch-kafka-superset-demo](https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo).
## Статус
Репозиторий строится по спеке
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
Сейчас готов первый элемент каркаса: кластер ClickHouse из двух шардов и
отдельного clickhouse-keeper.
Сейчас работают кластер ClickHouse из двух шардов, отдельный
clickhouse-keeper, односерверная Kafka в режиме KRaft, Airflow 3.3, Superset,
Prometheus, Grafana и общая база Postgres для метаданных.
## Быстрый старт
Нужны Docker с Compose и свободные порты `28123`, `28124`, `29000`, `29001`.
Настройки портов и образа лежат в `.env` и не попадают в git:
Нужны Docker с Compose. Полная проверка также использует `curl`, `jq`, `awk`,
`grep`, `sed`, `tail`, `sleep` и `timeout`. По умолчанию должны быть свободны
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
и `29092`. Для статической проверки `make config-test` нужен `uv`.
Стенд запускается без `.env`:
```bash
make up
make smoke
```
Чтобы изменить образы, порты или учебные учётные данные, скопируйте образец:
```bash
cp .env.example .env
@@ -22,32 +33,73 @@ make up
make smoke
```
`make up` поднимает единственный режим стенда: два шарда ClickHouse по одной
реплике и отдельный keeper. Команда ждёт здорового состояния всех трёх
контейнеров. Нода `clickhouse-01` служит инициатором DDL и позже будет точкой
подключения Airflow. К `clickhouse-02` позже подключится Superset.
Учётные данные Postgres и Grafana применяются при создании их томов.
После первого запуска меняйте их только вместе с `make clean`: команда удалит
все локальные данные стенда, а следующий `make up` создаст их с новыми
значениями.
`make smoke` одной командой проверяет описание кластера, макросы нод, связь с
keeper, создание `ReplicatedMergeTree` и `Distributed` через `ON CLUSTER`,
путь в keeper из макроса, вставку через первую ноду и чтение со второй,
очередь распределённых DDL и удаление временных таблиц. Скрипт печатает каждый
шаг и зелёный результат по-русски.
`make up` собирает локальный образ Superset, поднимает весь стенд и ждёт
здорового состояния долгоживущих контейнеров. Одноразовые `airflow-init` и
`superset-init` завершаются с кодом 0. Первый обновляет схему Airflow,
подготавливает администратора и подключение к `clickhouse-01`. Второй обновляет
Superset, создаёт администратора и импортирует подключение к `clickhouse-02`.
`make smoke-guards` проверяет саму smoke-команду: аварийную очистку, обработку
прерывания, подсказку о незапущенном стенде и окружение keeper.
`make smoke` проверяет согласованность `.env.example` с Compose, зависимости
машины, здоровье контейнеров, Kafka через порт машины, три цели Prometheus,
источник Grafana, компоненты Airflow, ручной запуск примера DAG, метаданные и
подключение Superset. В конце проверка ждёт 20 секунд покоя, печатает общую
память контейнеров и падает при превышении 3,4 ГБ. Временный топик Kafka и
проверочный запуск DAG удаляются.
Остановить контейнеры можно командой `make down`. Для холодного старта с
удалением данных используйте `make clean`.
`make smoke-cluster` запускает отдельную глубокую проверку ClickHouse: описание
кластера, макросы, связь с keeper, `ReplicatedMergeTree`, `Distributed`, очередь
распределённых DDL и очистку временных таблиц.
HTTP-интерфейсы доступны только с локальной машины:
`make config-test` проверяет Compose, синтаксис Bash и Python и пробельные
ошибки в diff без запуска стенда.
- нода 1 — `http://127.0.0.1:28123`;
- нода 2 — `http://127.0.0.1:28124`.
`make smoke-guards` сначала проверяет аварийную семантику кластерной проверки,
а затем останавливает Prometheus и убеждается, что общая проверка называет его
и завершается с ошибкой. В конце стенд восстанавливается.
Остановить контейнеры без удаления данных можно командой `make down`. Для
полного сброса с удалением всех именованных томов используйте `make clean`.
Повторный `make up` безопасен: одноразовая подготовка приложений идемпотентна.
## Состав и доступ
- `clickhouse-01` — инициатор DDL и точка подключения Airflow;
- `clickhouse-02` — точка подключения Superset;
- `clickhouse-keeper` — координатор кластера;
- `kafka` — один брокер KRaft;
- `postgres-metadata` — один Postgres с отдельными базами и пользователями
`airflow` и `superset`;
- `airflow-apiserver`, `airflow-scheduler` и `airflow-dag-processor`
Airflow 3.3 с LocalExecutor, без triggerer;
- `superset` — интерфейс и подготовленное подключение ClickHouse;
- `prometheus` и `grafana` — сбор и просмотр встроенных метрик ClickHouse.
Порты доступны только с локальной машины:
- нода 1 — `http://127.0.0.1:28123`, нативный порт `29000`;
- нода 2 — `http://127.0.0.1:28124`, нативный порт `29001`;
- Kafka — `127.0.0.1:29092`;
- Airflow — `http://127.0.0.1:28080`, пользователь `admin`, пароль `airflow`;
- Superset — `http://127.0.0.1:28088`, пользователь `admin`, пароль `superset`;
- Prometheus — `http://127.0.0.1:29090`;
- Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`.
У локального учебного кластера нет пароля: ноды используют общего пользователя
`default` для запросов `Distributed`. Порты поэтому привязаны к `127.0.0.1` и
не открыты во внешнюю сеть.
Пароли интерфейсов, пароли Postgres, ключи Airflow и Superset, отсутствие
пароля ClickHouse и отсутствие проверки доступа у Kafka и Prometheus —
намеренно простые и явно ненастоящие настройки локального учебного стенда. Это
не пример настройки защиты: не копируйте значения из `.env.example` в рабочую
среду. Все опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу
не опубликован.
В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом
шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология
2×2 намеренно не входят в стенд.
@@ -65,7 +117,27 @@ HTTP-интерфейсы доступны только с локальной м
создавал отдельный путь каждого шарда без вписанных вручную значений. Для
образа зафиксирован точный текущий
[LTS-выпуск 26.3.17.56](https://github.com/ClickHouse/ClickHouse/releases/tag/v26.3.17.56-lts);
серверы и keeper используют один образ.
серверы и keeper используют один образ. Настройки Kafka 4.3.1 сверены с
[примером односерверного KRaft](https://github.com/apache/kafka/blob/4.3.1/docker/examples/docker-compose-files/single-node/plaintext/docker-compose.yml).
Секция метрик взята из конфигурации закреплённого образа ClickHouse и проверена
на серверах и keeper. Подготовка источника Grafana сверена с
[официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/).
Prometheus собирает только встроенные метрики двух серверов и keeper; внешних
сборщиков, панелей и правил оповещения пока нет.
После изменения `infra/clickhouse/config.d/prometheus.xml` выполните
`docker compose restart clickhouse-01 clickhouse-02`: обычный `make up` не
перезапускает уже созданные серверы и они продолжают работать со старой
конфигурацией.
Airflow закреплён на 3.3.0. Состав обязательных процессов, LocalExecutor,
публичный `airflow.sdk`, API здоровья и SimpleAuthManager сверены с
[архитектурой Airflow 3.3](https://airflow.apache.org/docs/apache-airflow/3.3.0/core-concepts/overview.html),
[публичным интерфейсом](https://airflow.apache.org/docs/apache-airflow/3.3.0/public-airflow-interface.html)
и [описанием здоровья](https://airflow.apache.org/docs/apache-airflow/3.3.0/administration-and-deployment/logging-monitoring/check-health.html).
Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, форма
`clickhousedb://` и драйвер Postgres сверены с
[документацией подключений Superset](https://superset.apache.org/user-docs/6.1.0/databases/)
и [настройкой базы метаданных](https://superset.apache.org/admin-docs/6.1.0/configuration/configuring-superset/).
## Что здесь будет
+258
View File
@@ -23,6 +23,48 @@ x-clickhouse-common: &clickhouse-common
retries: 30
start_period: 10s
x-airflow-common: &airflow-common
image: ${AIRFLOW_IMAGE:-apache/airflow:3.3.0}
environment:
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__CORE__PARALLELISM: "4"
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
AIRFLOW__CORE__AUTH_MANAGER: airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:-admin}:admin"
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE: /opt/airflow/auth/simple_auth_manager_passwords.json
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-apiserver:8080/execution/
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=}
AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:-local-only-airflow-jwt-secret-do-not-use}
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:-local-only-airflow-api-secret-do-not-use}
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: "postgresql+psycopg2://${AIRFLOW_METADATA_USER:-airflow}:${AIRFLOW_METADATA_PASSWORD:-airflow-local}@postgres-metadata:5432/airflow"
AIRFLOW__SCHEDULER__ENABLE_HEALTH_CHECK: "true"
AIRFLOW_ADMIN_USER: ${AIRFLOW_ADMIN_USER:-admin}
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-airflow}
volumes:
- ./dags:/opt/airflow/dags:ro
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
- airflow_logs:/opt/airflow/logs
- airflow_auth:/opt/airflow/auth
x-superset-common: &superset-common
image: clickstream-superset:local
build:
context: ./infra/superset
args:
SUPERSET_BASE_IMAGE: ${SUPERSET_IMAGE:-apache/superset:6.1.0}
environment:
SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:-local-only-superset-secret-do-not-use-outside-this-stand}
SUPERSET__SQLALCHEMY_DATABASE_URI: "postgresql+psycopg2://${SUPERSET_METADATA_USER:-superset}:${SUPERSET_METADATA_PASSWORD:-superset-local}@postgres-metadata:5432/superset"
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:-admin}
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD:-superset}
volumes:
- ./infra/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
- ./infra/superset/init.sh:/app/infra/init.sh:ro
- ./infra/superset/import:/app/import:ro
- superset_home:/app/superset_home
services:
clickhouse-keeper:
image: ${CLICKHOUSE_IMAGE:-clickhouse/clickhouse-server:26.3.17.56}
@@ -52,6 +94,7 @@ services:
- "127.0.0.1:${CLICKHOUSE_01_TCP_PORT:-29000}:9000"
volumes:
- ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
- ./infra/clickhouse/config.d/prometheus.xml:/etc/clickhouse-server/config.d/prometheus.xml:ro
- ./infra/clickhouse/config.d/macros-01.xml:/etc/clickhouse-server/config.d/macros.xml:ro
- clickhouse_01_data:/var/lib/clickhouse
@@ -63,10 +106,225 @@ services:
- "127.0.0.1:${CLICKHOUSE_02_TCP_PORT:-29001}:9000"
volumes:
- ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
- ./infra/clickhouse/config.d/prometheus.xml:/etc/clickhouse-server/config.d/prometheus.xml:ro
- ./infra/clickhouse/config.d/macros-02.xml:/etc/clickhouse-server/config.d/macros.xml:ro
- clickhouse_02_data:/var/lib/clickhouse
kafka:
image: ${KAFKA_IMAGE:-apache/kafka:4.3.1}
hostname: kafka
restart: unless-stopped
ports:
- "127.0.0.1:${KAFKA_EXTERNAL_PORT:-29092}:29092"
environment:
KAFKA_NODE_ID: "1"
KAFKA_PROCESS_ROLES: "broker,controller"
KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093"
KAFKA_LISTENERS: "INTERNAL://:9092,CONTROLLER://:9093,EXTERNAL://:29092"
KAFKA_ADVERTISED_LISTENERS: "INTERNAL://kafka:9092,EXTERNAL://localhost:${KAFKA_EXTERNAL_PORT:-29092}"
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "INTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT"
KAFKA_INTER_BROKER_LISTENER_NAME: "INTERNAL"
KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "1"
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: "1"
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: "1"
KAFKA_SHARE_COORDINATOR_STATE_TOPIC_REPLICATION_FACTOR: "1"
KAFKA_SHARE_COORDINATOR_STATE_TOPIC_MIN_ISR: "1"
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: "0"
KAFKA_NUM_PARTITIONS: "1"
KAFKA_LOG_DIRS: "/var/lib/kafka/data"
KAFKA_HEAP_OPTS: "-Xmx512m -Xms256m"
CLUSTER_ID: "4L6g3nShT-eMCtK--X86sw"
volumes:
- kafka_data:/var/lib/kafka/data
mem_limit: 1g
healthcheck:
test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list >/dev/null 2>&1"]
interval: 15s
timeout: 10s
retries: 20
start_period: 30s
postgres-metadata:
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
restart: unless-stopped
command:
- postgres
- -c
- shared_buffers=64MB
- -c
- max_connections=50
environment:
POSTGRES_USER: ${POSTGRES_ADMIN_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:-postgres-local}
POSTGRES_DB: postgres
AIRFLOW_METADATA_USER: ${AIRFLOW_METADATA_USER:-airflow}
AIRFLOW_METADATA_PASSWORD: ${AIRFLOW_METADATA_PASSWORD:-airflow-local}
SUPERSET_METADATA_USER: ${SUPERSET_METADATA_USER:-superset}
SUPERSET_METADATA_PASSWORD: ${SUPERSET_METADATA_PASSWORD:-superset-local}
volumes:
- ./infra/postgres/init/10-metadata-databases.sh:/docker-entrypoint-initdb.d/10-metadata-databases.sh:ro
- postgres_metadata_data:/var/lib/postgresql/data
mem_limit: 256m
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d postgres >/dev/null"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
airflow-init:
<<: *airflow-common
user: "0:0"
restart: "no"
depends_on:
postgres-metadata:
condition: service_healthy
clickhouse-01:
condition: service_healthy
entrypoint: ["/bin/bash"]
command: ["/opt/airflow/init.sh"]
airflow-apiserver:
<<: *airflow-common
restart: unless-stopped
depends_on:
airflow-init:
condition: service_completed_successfully
command: api-server
ports:
- "127.0.0.1:${AIRFLOW_PORT:-28080}:8080"
mem_limit: 512m
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/api/v2/monitor/health | python -c 'import json,sys; sys.exit(0 if json.load(sys.stdin)[\"metadatabase\"][\"status\"] == \"healthy\" else 1)'"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
airflow-scheduler:
<<: *airflow-common
restart: unless-stopped
depends_on:
airflow-init:
condition: service_completed_successfully
command: scheduler
mem_limit: 640m
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8974/health >/dev/null"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
airflow-dag-processor:
<<: *airflow-common
restart: unless-stopped
depends_on:
airflow-init:
condition: service_completed_successfully
airflow-apiserver:
condition: service_healthy
command: dag-processor
mem_limit: 384m
healthcheck:
test: ["CMD-SHELL", "curl -sf http://airflow-apiserver:8080/api/v2/monitor/health | python -c 'import json,sys; sys.exit(0 if json.load(sys.stdin)[\"dag_processor\"][\"status\"] == \"healthy\" else 1)'"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
superset-init:
<<: *superset-common
restart: "no"
healthcheck:
disable: true
depends_on:
postgres-metadata:
condition: service_healthy
clickhouse-02:
condition: service_healthy
entrypoint: ["/bin/bash"]
command: ["/app/infra/init.sh"]
superset:
<<: *superset-common
restart: unless-stopped
depends_on:
superset-init:
condition: service_completed_successfully
ports:
- "127.0.0.1:${SUPERSET_PORT:-28088}:8088"
mem_limit: 1g
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8088/health >/dev/null"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
prometheus:
image: ${PROMETHEUS_IMAGE:-prom/prometheus:v3.13.2}
restart: unless-stopped
depends_on:
clickhouse-keeper:
condition: service_healthy
clickhouse-01:
condition: service_healthy
clickhouse-02:
condition: service_healthy
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=7d
- --storage.tsdb.retention.size=512MB
volumes:
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
ports:
- "127.0.0.1:${PROMETHEUS_PORT:-29090}:9090"
mem_limit: 512m
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9090/-/ready >/dev/null 2>&1"]
interval: 10s
timeout: 5s
retries: 15
start_period: 10s
grafana:
image: ${GRAFANA_IMAGE:-grafana/grafana:13.1.1}
restart: unless-stopped
depends_on:
prometheus:
condition: service_healthy
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
GF_USERS_ALLOW_SIGN_UP: "false"
GF_NEWS_NEWS_FEED_ENABLED: "false"
GF_ANALYTICS_REPORTING_ENABLED: "false"
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
volumes:
- ./infra/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- grafana_data:/var/lib/grafana
ports:
- "127.0.0.1:${GRAFANA_PORT:-23000}:3000"
mem_limit: 512m
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:3000/api/health >/dev/null"]
interval: 10s
timeout: 5s
retries: 15
start_period: 15s
volumes:
clickhouse_keeper_data:
clickhouse_01_data:
clickhouse_02_data:
kafka_data:
postgres_metadata_data:
airflow_logs:
airflow_auth:
superset_home:
prometheus_data:
grafana_data:
+34
View File
@@ -0,0 +1,34 @@
"""Пример независимого DAG для проверки Airflow 3."""
from __future__ import annotations
import datetime
from airflow.sdk import dag, task
@dag(
dag_id="example_clickstream_hello",
schedule=None,
start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
catchup=False,
tags=["пример"],
doc_md=__doc__,
)
def example_clickstream_hello():
@task
def extract() -> dict[str, int]:
return {"clicks": 3, "views": 10}
@task
def calculate_ctr(counters: dict[str, int]) -> float:
return round(counters["clicks"] / counters["views"], 3)
@task
def show_result(ctr: float) -> None:
print(f"CTR учебного примера: {ctr}")
show_result(calculate_ctr(extract()))
example_clickstream_hello()
+70
View File
@@ -0,0 +1,70 @@
# ADR 0001. Сервисы каркаса стенда
Дата: 30 июля 2026 года. Статус: принято.
## Решение
Стенд использует Airflow 3.3.0 с LocalExecutor. Airflow разделён на
одноразовый `airflow-init` и три долгоживущих процесса: API, планировщик и
обработчик DAG. Triggerer не запускается: в каркасе нет отложенных задач.
Пример DAG написан через публичный `airflow.sdk`.
Для входа выбран SimpleAuthManager. У него нет команды создания пользователя,
поэтому `airflow-init` записывает пароль администратора из окружения в
JSON-файл отдельного тома `airflow_auth`. Новый именованный том принадлежит
`root`, а Airflow работает от пользователя `airflow`. Поэтому `airflow-init`
запускается от `root`, назначает владельца каталога и сразу выполняет Python и
команды Airflow через `runuser` от пользователя `airflow`. Все файлы
репозитория подключены к этому контейнеру только для чтения. JWT-секрет, ключ
подписи ссылок на журналы и ключ Fernet одинаковы для всех процессов и
приходят из окружения.
Один Postgres 16 хранит две базы метаданных. У Airflow и Superset разные базы,
пользователи и строки подключения. Это экономит один контейнер, но сохраняет
границу владения данными.
Airflow получает подготовленное общее подключение к `clickhouse-01`, а
Superset — подключение `clickhousedb://` к `clickhouse-02`. Провайдер
ClickHouse для Airflow пока не нужен. Разные ноды создают учебную ловушку:
забытый `ON CLUSTER` проявится в Superset, даже если операция Airflow на первой
ноде прошла успешно.
Superset собирается от `apache/superset:6.1.0`. В образ добавлены
`clickhouse-connect` для ClickHouse и `psycopg2-binary` для метаданных
Postgres. Подключение ClickHouse импортируется из YAML при подготовке.
Prometheus читает встроенные точки метрик двух серверов ClickHouse и keeper.
Отдельные сборщики не нужны. Grafana получает источник Prometheus из файла.
## Почему
Разделение Airflow показывает архитектуру третьей версии и даёт честные
проверки здоровья процессов. LocalExecutor достаточен для одного учебного
компьютера. Отказ от triggerer, второго Postgres и внешних сборщиков удерживает
стенд в пределе 3,4 ГБ.
Тома `clickhouse_*_data` хранят данные keeper и двух нод ClickHouse.
`kafka_data`, `postgres_metadata_data`, `superset_home`, `prometheus_data` и
`grafana_data` хранят состояние своих сервисов. `airflow_logs` хранит журналы,
а `airflow_auth` — JSON-файл с учебным паролем администратора. Каталог `dags/`
и все файлы настройки подключены только для чтения, поэтому контейнеры не
меняют рабочее дерево.
## Что проверено
Исследование задачи сверило через Context7 публичный API DAG с Task SDK Airflow
и аргумент `schedule`. Решение дополнительно проверено по документации Airflow
3.3.0: [публичный интерфейс](https://airflow.apache.org/docs/apache-airflow/3.3.0/public-airflow-interface.html),
[архитектура процессов](https://airflow.apache.org/docs/apache-airflow/3.3.0/core-concepts/overview.html),
[SimpleAuthManager](https://airflow.apache.org/docs/apache-airflow/3.3.0/core-concepts/auth-manager/simple/index.html)
и [API здоровья](https://airflow.apache.org/docs/apache-airflow/3.3.0/administration-and-deployment/logging-monitoring/check-health.html).
Оттуда взяты `airflow.sdk`, обязательный отдельный обработчик DAG, возможность
не запускать triggerer и проверка конкретных компонентов здоровья.
Драйверы и строки подключения проверены по документации Superset 6.1.0:
[подключения к базам](https://superset.apache.org/user-docs/6.1.0/databases/),
[ClickHouse](https://superset.apache.org/user-docs/databases/supported/clickhouse/)
и [база метаданных](https://superset.apache.org/admin-docs/6.1.0/configuration/configuring-superset/).
Форма декларативного импорта и команда `test-db` проверены по исходному коду
Superset 6.1.0 и на запущенном образе. Конфигурация метрик проверена по файлам
закреплённого образа ClickHouse 26.3.17.56 и на живых точках серверов и keeper.
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
install -d -o airflow -g root -m 0775 /opt/airflow/auth
# SimpleAuthManager не умеет создавать пользователей через CLI: пароль хранится в JSON.
runuser -u airflow -- python - <<'PY'
import json
import os
from pathlib import Path
passwords_file = Path(os.environ["AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE"])
passwords_file.write_text(
json.dumps(
{os.environ["AIRFLOW_ADMIN_USER"]: os.environ["AIRFLOW_ADMIN_PASSWORD"]},
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
PY
runuser -u airflow -- airflow db migrate
runuser -u airflow -- airflow connections delete clickhouse_default >/dev/null 2>&1 || true
runuser -u airflow -- airflow connections add clickhouse_default \
--conn-type generic \
--conn-host clickhouse-01 \
--conn-port 8123 \
--conn-login default \
--conn-schema default \
--conn-description "ClickHouse, нода 1; типизированный провайдер появится на этапе ETL"
+11
View File
@@ -0,0 +1,11 @@
<clickhouse>
<!-- Встроенная точка метрик проверена на образе ClickHouse 26.3.17.56. -->
<prometheus>
<endpoint>/metrics</endpoint>
<port>9363</port>
<metrics>true</metrics>
<events>true</events>
<asynchronous_metrics>true</asynchronous_metrics>
<errors>true</errors>
</prometheus>
</clickhouse>
+9 -1
View File
@@ -6,6 +6,15 @@
</logger>
<listen_host>0.0.0.0</listen_host>
<!-- Метрики keeper; схема проверена на образе ClickHouse 26.3.17.56. -->
<prometheus>
<endpoint>/metrics</endpoint>
<port>9363</port>
<metrics>true</metrics>
<events>true</events>
<asynchronous_metrics>true</asynchronous_metrics>
</prometheus>
<keeper_server>
<tcp_port>9181</tcp_port>
<server_id>1</server_id>
@@ -24,4 +33,3 @@
</raft_configuration>
</keeper_server>
</clickhouse>
@@ -0,0 +1,10 @@
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
# Один сервер, но отдельные владельцы и базы не смешивают метаданные приложений.
psql --set=ON_ERROR_STOP=1 \
--set=airflow_user="$AIRFLOW_METADATA_USER" \
--set=airflow_password="$AIRFLOW_METADATA_PASSWORD" \
--set=superset_user="$SUPERSET_METADATA_USER" \
--set=superset_password="$SUPERSET_METADATA_PASSWORD" \
--username "$POSTGRES_USER" \
--dbname postgres <<'SQL'
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'airflow_user', :'airflow_password')
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'airflow_user') \gexec
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'superset_user', :'superset_password')
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'superset_user') \gexec
SELECT format('CREATE DATABASE airflow OWNER %I', :'airflow_user')
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'airflow') \gexec
SELECT format('CREATE DATABASE superset OWNER %I', :'superset_user')
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'superset') \gexec
SQL
+18
View File
@@ -0,0 +1,18 @@
global:
scrape_interval: 15s
scrape_timeout: 10s
scrape_configs:
- job_name: clickhouse
static_configs:
- targets:
- clickhouse-01:9363
- clickhouse-02:9363
labels:
role: server
- job_name: clickhouse-keeper
static_configs:
- targets:
- clickhouse-keeper:9363
labels:
role: keeper
+12
View File
@@ -0,0 +1,12 @@
ARG SUPERSET_BASE_IMAGE
FROM ${SUPERSET_BASE_IMAGE}
USER root
# В основном образе нет ни драйвера ClickHouse, ни драйвера Postgres.
RUN . /app/.venv/bin/activate \
&& uv pip install --no-cache \
"clickhouse-connect==1.6.0" \
"psycopg2-binary==2.9.12"
USER superset
@@ -0,0 +1,14 @@
database_name: ClickHouse
sqlalchemy_uri: clickhousedb://default@clickhouse-02:8123/default
uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
cache_timeout: null
expose_in_sqllab: true
allow_run_async: false
allow_ctas: false
allow_cvas: false
allow_dml: false
allow_file_upload: false
impersonate_user: false
extra:
allows_virtual_table_explore: true
version: 1.0.0
+3
View File
@@ -0,0 +1,3 @@
version: 1.0.0
type: Database
timestamp: '2026-07-30T00:00:00+00:00'
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
superset db upgrade
if python - <<'PY'
import os
from sqlalchemy import create_engine, text
engine = create_engine(os.environ["SUPERSET__SQLALCHEMY_DATABASE_URI"])
with engine.connect() as connection:
exists = connection.scalar(
text("SELECT EXISTS (SELECT 1 FROM ab_user WHERE username = :username)"),
{"username": os.environ["SUPERSET_ADMIN_USER"]},
)
raise SystemExit(0 if exists else 1)
PY
then
superset fab reset-password \
--username "$SUPERSET_ADMIN_USER" \
--password "$SUPERSET_ADMIN_PASSWORD"
else
superset fab create-admin \
--username "$SUPERSET_ADMIN_USER" \
--password "$SUPERSET_ADMIN_PASSWORD" \
--firstname Superset \
--lastname Admin \
--email admin@example.invalid
fi
superset init
superset import-directory /app/import --overwrite
+17
View File
@@ -0,0 +1,17 @@
"""Минимальные настройки локального учебного Superset."""
import os
SQLALCHEMY_DATABASE_URI = os.environ["SUPERSET__SQLALCHEMY_DATABASE_URI"]
SECRET_KEY = os.environ["SUPERSET_SECRET_KEY"]
SESSION_COOKIE_NAME = "superset_session"
SESSION_COOKIE_SECURE = False
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
WTF_CSRF_ENABLED = True
CACHE_CONFIG = {
"CACHE_TYPE": "SimpleCache",
"CACHE_DEFAULT_TIMEOUT": 300,
}
+2 -2
View File
@@ -61,14 +61,14 @@ on_exit() {
if [[ "$status" -ne 0 ]]; then
printf 'Сбой проверки: выполняется быстрая очистка временных таблиц...\n' >&2
fi
cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make smoke.\n' >&2
cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make smoke-cluster.\n' >&2
exit "$status"
}
on_signal() {
trap - EXIT INT TERM
printf 'Проверка прервана пользователем: выполняется быстрая очистка временных таблиц...\n' >&2
cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make smoke.\n' >&2
cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make smoke-cluster.\n' >&2
exit 130
}
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
read -r -a COMPOSE_CMD <<<"${COMPOSE_BIN:-docker compose}"
readonly CACHE_DIR="$(mktemp -d)"
readonly SHELL_FILES_LIST="$CACHE_DIR/shell-files"
cleanup() {
rm -rf "$CACHE_DIR"
}
trap cleanup EXIT
"${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" config --quiet
config_json="$("${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" config --format json)"
grep -qx 'ARG SUPERSET_BASE_IMAGE' "$ROOT_DIR/infra/superset/Dockerfile"
jq -e '
.services.superset.image == "clickstream-superset:local" and
(.services.superset.build.args.SUPERSET_BASE_IMAGE | length > 0)
' >/dev/null <<<"$config_json"
jq -e '
.services["airflow-init"] as $service |
($service.environment.AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE ==
"/opt/airflow/auth/simple_auth_manager_passwords.json") and
any($service.volumes[]; .source == "airflow_auth" and .target == "/opt/airflow/auth") and
any($service.volumes[]; .source == "airflow_logs" and .target == "/opt/airflow/logs")
' >/dev/null <<<"$config_json"
if ! find "$ROOT_DIR" \
-path "$ROOT_DIR/.git" -prune -o \
-type f -name '*.sh' -print0 >"$SHELL_FILES_LIST"; then
printf 'ОШИБКА: не удалось получить список файлов Bash для проверки.\n' >&2
exit 1
fi
mapfile -d '' -t shell_files <"$SHELL_FILES_LIST"
if [[ "${#shell_files[@]}" -eq 0 ]]; then
printf 'ОШИБКА: не найдено ни одного файла Bash для проверки.\n' >&2
exit 1
fi
bash -n "${shell_files[@]}"
PYTHONPYCACHEPREFIX="$CACHE_DIR" uv run --no-project python -m compileall -q "$ROOT_DIR/dags"
git -C "$ROOT_DIR" diff --check
printf 'ЗЕЛЁНО: Compose, Bash, Python и пробельные ошибки diff проверены.\n'
+613
View File
@@ -0,0 +1,613 @@
#!/usr/bin/env bash
set -uo pipefail
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly COMPOSE_FILE="$ROOT_DIR/compose.yaml"
readonly ENV_EXAMPLE="$ROOT_DIR/.env.example"
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_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_env_consistency() {
local LC_ALL=C
local content
local default
local depth
local env_value
local expression
local found_closing
local i
local inner
local joined
local j
local length
local line
local nested
local variable
local -A env_count=()
local -A env_values=()
local -a problems=()
local -A used=()
local -a expressions=()
if [[ ! -r "$COMPOSE_FILE" || ! -r "$ENV_EXAMPLE" ]]; then
fail 'compose.yaml или .env.example недоступны для чтения'
return
fi
# Разбираем только подстановки Compose и отдельно пропускаем $$ для команд контейнера.
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%$'\r'}"
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
variable="${BASH_REMATCH[1]}"
env_count["$variable"]=$(( ${env_count[$variable]:-0} + 1 ))
env_values["$variable"]="${BASH_REMATCH[2]}"
fi
done <"$ENV_EXAMPLE"
content="$(<"$COMPOSE_FILE")"
length="${#content}"
for ((i = 0; i < length - 1; i++)); do
if [[ "${content:i:2}" == '$$' ]]; then
i=$((i + 1))
continue
fi
if [[ "${content:i:2}" != '${' ]]; then
continue
fi
depth=1
found_closing=0
nested=0
for ((j = i + 2; j < length; j++)); do
if [[ "${content:j:2}" == '${' ]]; then
nested=1
depth=$((depth + 1))
j=$((j + 1))
elif [[ "${content:j:1}" == '}' ]]; then
depth=$((depth - 1))
if [[ "$depth" -eq 0 ]]; then
expressions+=("${content:i:j-i+1}")
found_closing=1
i="$j"
break
fi
fi
done
if [[ "$found_closing" -eq 0 ]]; then
problems+=("незакрытая подстановка у позиции ${i}")
break
fi
if [[ "$nested" -eq 1 ]]; then
problems+=("${expressions[-1]}: вложенные подстановки запрещены, используйте простой вид \${VAR:-значение}")
fi
done
for expression in "${expressions[@]}"; do
inner="${expression:2:${#expression}-3}"
if [[ "$inner" =~ ^([A-Za-z_][A-Za-z0-9_]*):-(.*)$ ]]; then
variable="${BASH_REMATCH[1]}"
default="${BASH_REMATCH[2]}"
used["$variable"]=1
if [[ "${env_count[$variable]:-0}" -ne 1 ]]; then
problems+=("${variable}: нужна ровно одна строка в .env.example")
continue
fi
env_value="${env_values[$variable]}"
if [[ "$env_value" != "$default" ]]; then
problems+=("${variable}: значение '${env_value}' не равно '${default}'")
fi
else
problems+=("${expression}: нет значения по умолчанию вида :-")
fi
done
for variable in "${!env_count[@]}"; do
if [[ -z "${used[$variable]+x}" ]]; then
problems+=("${variable}: не используется в compose.yaml")
fi
done
if [[ "${#problems[@]}" -eq 0 ]]; then
pass '.env.example совпадает со всеми значениями по умолчанию compose.yaml'
else
printf -v joined '%s; ' "${problems[@]}"
fail "расхождение .env.example и compose.yaml: ${joined%; }"
fi
}
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() {
local container_id
local service="$1"
local state
container_id="$(compose ps --all --quiet "$service" 2>/dev/null || true)"
if [[ -z "$container_id" ]]; then
fail "сервис ${service} не создан"
return
fi
state="$(docker inspect --format '{{.State.Status}}/{{if .State.Health}}{{.State.Health.Status}}{{else}}нет проверки состояния{{end}}' "$container_id" 2>/dev/null || true)"
if [[ "$state" == 'running/healthy' ]]; then
pass "сервис ${service} запущен и здоров"
else
fail "сервис ${service} нездоров: ${state:-состояние неизвестно}"
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/example_clickstream_hello/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/example_clickstream_hello" \
>/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
local targets_response
port="$(published_port prometheus 9090)"
response="$(curl -sf "http://127.0.0.1:${port}/api/v1/query?query=up" 2>/dev/null || true)"
targets_response="$(curl -sf "http://127.0.0.1:${port}/api/v1/targets?state=active" 2>/dev/null || true)"
if jq -e '
.status == "success" and
(.data.result | length == 3) and
([.data.result[].metric.instance] | sort ==
["clickhouse-01:9363", "clickhouse-02:9363", "clickhouse-keeper:9363"]) and
all(.data.result[]; .value[1] == "1")
' >/dev/null 2>&1 <<<"$response" && jq -e '
.status == "success" and
(.data.activeTargets | length == 3) and
([.data.activeTargets[].scrapeUrl] | sort == [
"http://clickhouse-01:9363/metrics",
"http://clickhouse-02:9363/metrics",
"http://clickhouse-keeper:9363/metrics"
]) and
all(.data.activeTargets[]; .health == "up" and .lastError == "")
' >/dev/null 2>&1 <<<"$targets_response"; then
pass 'Prometheus видит ровно три цели ClickHouse, все со значением up=1'
else
fail 'Prometheus не видит ровно три здоровые цели: clickhouse-01, clickhouse-02 и clickhouse-keeper'
fi
}
check_grafana_datasource() {
local config
local password
local port
local datasource
local provisioning_file_ok=0
local response
local user
config="$(compose config --format json 2>/dev/null || true)"
user="$(jq -r '.services.grafana.environment.GF_SECURITY_ADMIN_USER // empty' <<<"$config")"
password="$(jq -r '.services.grafana.environment.GF_SECURITY_ADMIN_PASSWORD // empty' <<<"$config")"
port="$(published_port grafana 3000)"
timeout 20s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \
exec -T grafana sh -ec '
file=/etc/grafana/provisioning/datasources/prometheus.yml
test -r "$file"
grep -Eq "^[[:space:]]+uid: prometheus$" "$file"
grep -Eq "^[[:space:]]+type: prometheus$" "$file"
grep -Eq "^[[:space:]]+url: http://prometheus:9090$" "$file"
grep -Eq "^[[:space:]]+isDefault: true$" "$file"
grep -Eq "^[[:space:]]+editable: false$" "$file"
' >/dev/null 2>&1 && provisioning_file_ok=1
datasource="$(curl -sf -u "${user}:${password}" \
"http://127.0.0.1:${port}/api/datasources/uid/prometheus" 2>/dev/null || true)"
response="$(curl -sf -u "${user}:${password}" \
"http://127.0.0.1:${port}/api/datasources/uid/prometheus/health" 2>/dev/null || true)"
if [[ "$provisioning_file_ok" -eq 1 ]] && jq -e '
.uid == "prometheus" and
.type == "prometheus" and
.url == "http://prometheus:9090" and
.access == "proxy" and
.isDefault == true and
.readOnly == true
' >/dev/null 2>&1 <<<"$datasource" && \
jq -e '.status == "OK"' >/dev/null 2>&1 <<<"$response"; then
pass 'Grafana проверила файл настройки и подготовленный источник Prometheus с uid=prometheus'
else
fail 'Grafana не смогла проверить файл настройки и подготовленный источник Prometheus с uid=prometheus'
fi
}
check_airflow() {
local config
local connection
local dag
local encoded_run_id
local health
local password
local response
local run_state=''
local unpaused
local user
local -i attempt
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)"
health="$(curl -sf --max-time 10 \
"http://127.0.0.1:${airflow_cleanup_port}/api/v2/monitor/health" 2>/dev/null || true)"
if jq -e '
.metadatabase.status == "healthy" and
.scheduler.status == "healthy" and
.dag_processor.status == "healthy"
' >/dev/null 2>&1 <<<"$health"; then
pass 'API Airflow отвечает; база метаданных, планировщик и обработчик DAG здоровы'
else
fail 'API Airflow не подтвердил здоровье базы метаданных, планировщика и обработчика DAG'
fi
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_cleanup_port}/auth/token" 2>/dev/null || true)"
airflow_cleanup_token="$(jq -r '.access_token // empty' <<<"$response")"
dag="$(curl -sf --max-time 10 \
-H "Authorization: Bearer ${airflow_cleanup_token}" \
"http://127.0.0.1:${airflow_cleanup_port}/api/v2/dags/example_clickstream_hello" \
2>/dev/null || true)"
if [[ -n "$airflow_cleanup_token" ]] && \
jq -e '.dag_id == "example_clickstream_hello"' >/dev/null 2>&1 <<<"$dag"; then
airflow_cleanup_original_paused="$(jq -r '.is_paused | tostring' <<<"$dag")"
pass 'учётные данные администратора Airflow принимаются, пример DAG виден через API'
else
fail 'Airflow не принял учётные данные администратора или не показал пример DAG'
return
fi
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/example_clickstream_hello" \
2>/dev/null || true)"
if ! jq -e '.is_paused == false' >/dev/null 2>&1 <<<"$unpaused"; then
fail 'Airflow не смог включить пример DAG перед ручным запуском'
return
fi
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" \
2>/dev/null || true)"
if jq -e '
.connection_id == "clickhouse_default" and
.host == "clickhouse-01" and
.port == 8123 and
.login == "default" and
.schema == "default"
' >/dev/null 2>&1 <<<"$connection" && \
timeout 20s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \
exec -T airflow-scheduler \
curl -sf 'http://clickhouse-01:8123/?query=SELECT%201' \
2>/dev/null | grep -qx '1'; then
pass 'подготовленное подключение Airflow указывает на clickhouse-01, нода доступна из контейнера'
else
fail 'подключение Airflow не указывает на clickhouse-01 или нода недоступна из контейнера'
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/example_clickstream_hello/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'
return
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/example_clickstream_hello/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 завершился успешно, удалён, исходная пауза восстановлена'
else
fail "ручной запуск примера DAG не завершился чисто: состояние ${run_state:-неизвестно}"
fi
}
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
}
check_memory_budget() {
local -a ids=()
local container_id
local service
local total_mib
sleep 20
for service in "${LONG_LIVED_SERVICES[@]}"; do
container_id="$(compose ps --status running --quiet "$service" 2>/dev/null || true)"
if [[ -z "$container_id" || "$container_id" == *$'\n'* ]]; then
fail "не удалось получить работающий контейнер ${service} для измерения памяти"
return
fi
ids+=("$container_id")
done
if ! total_mib="$(docker stats --no-stream --format '{{.MemUsage}}' "${ids[@]}" 2>/dev/null | awk -v expected="${#ids[@]}" '
$1 ~ /GiB$/ {sub(/GiB$/, "", $1); total += $1 * 1024; next}
$1 ~ /MiB$/ {sub(/MiB$/, "", $1); total += $1; next}
$1 ~ /KiB$/ {sub(/KiB$/, "", $1); total += $1 / 1024; next}
$1 ~ /GB$/ {sub(/GB$/, "", $1); total += $1 * 1000 / 1.048576; next}
$1 ~ /MB$/ {sub(/MB$/, "", $1); total += $1 / 1.048576; next}
$1 ~ /kB$/ {sub(/kB$/, "", $1); total += $1 / 1048.576; next}
{invalid = 1}
END {
if (invalid || NR != expected) exit 1
printf "%.1f", total
}
')"; then
fail 'Docker не вернул полное измерение памяти стенда'
return
fi
if awk -v total="$total_mib" 'BEGIN {exit !(total <= 3242.5)}'; then
pass "стенд занимает ${total_mib} MiB после 20 секунд покоя, порог 3,4 ГБ не превышен"
else
fail "стенд занимает ${total_mib:-неизвестно} MiB после 20 секунд покоя, это больше 3,4 ГБ"
fi
}
check_env_consistency
if check_host_dependencies; then
for service in "${LONG_LIVED_SERVICES[@]}"; do
check_container_health "$service"
done
check_kafka_from_host
check_prometheus_targets
check_grafana_datasource
check_airflow
check_superset
check_memory_budget
else
fail 'проверки контейнеров, Kafka, Airflow, Superset, Prometheus и Grafana пропущены без зависимостей машины'
fi
printf 'ИТОГ: пройдено %d, ошибок %d\n' "$passed" "$failed"
[[ "$failed" -eq 0 ]]
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly README="$ROOT_DIR/README.md"
grep -Eq 'нода 1.*28123.*29000' "$README"
grep -Eq 'нода 2.*28124.*29001' "$README"
printf 'ЗЕЛЁНО: README перечисляет HTTP- и нативные порты обеих нод.\n'
grep -Eq 'После первого запуска.*`make clean`' "$README"
printf 'ЗЕЛЁНО: README объясняет сброс томов после смены исходных учётных данных.\n'
grep -Fxq -- '- нода 2 — `http://127.0.0.1:28124`, нативный порт `29001`;' "$README"
printf 'ЗЕЛЁНО: список портов остаётся единым списком.\n'
awk '
previous == "После изменения `infra/clickhouse/config.d/prometheus.xml` выполните" &&
$0 == "`docker compose restart clickhouse-01 clickhouse-02`: обычный `make up` не" {
found = 1
}
{previous = $0}
END {exit !found}
' "$README"
printf 'ЗЕЛЁНО: README требует перезапуск ClickHouse после изменения настройки метрик.\n'
grep_status=0
grep -q '3,4 GB' "$ROOT_DIR/scripts/stand-smoke.sh" || grep_status=$?
if [[ "$grep_status" -eq 0 ]]; then
printf 'ОШИБКА: отчёт проверки использует латинское обозначение GB.\n' >&2
exit 1
elif [[ "$grep_status" -ne 1 ]]; then
printf 'ОШИБКА: не удалось проверить обозначение единицы памяти.\n' >&2
exit 1
fi
printf 'ЗЕЛЁНО: отчёт проверки использует русское обозначение ГБ.\n'
printf 'ИТОГ: пройдено 5, ошибок 0\n'
+1 -1
View File
@@ -61,7 +61,7 @@ check_failure_guards() {
failure_log="$(mktemp)"
started_at="$(date +%s)"
setsid make --no-print-directory -C "$ROOT_DIR" smoke >"$failure_log" 2>&1 &
setsid make --no-print-directory -C "$ROOT_DIR" smoke-cluster >"$failure_log" 2>&1 &
failure_pid=$!
if wait_for_local_table; then
compose kill clickhouse-02 >/dev/null
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -uo pipefail
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
read -r -a COMPOSE_CMD <<<"${COMPOSE_BIN:-docker compose}"
readonly LOG_FILE="$(mktemp)"
restored=0
passed=0
compose() {
"${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" "$@"
}
restore_stand() {
make --no-print-directory -C "$ROOT_DIR" COMPOSE="${COMPOSE_BIN:-docker compose}" up >/dev/null
}
on_exit() {
local status=$?
trap - EXIT INT TERM
rm -f "$LOG_FILE"
if [[ "$restored" -eq 0 ]] && ! restore_stand; then
printf 'ОШИБКА: не удалось восстановить стенд после проверки.\n' >&2
status=1
fi
exit "$status"
}
trap on_exit EXIT
trap 'exit 130' INT TERM
if make --no-print-directory -C "$ROOT_DIR" COMPOSE="${COMPOSE_BIN:-docker compose}" smoke >"$LOG_FILE" 2>&1; then
passed=$((passed + 1))
printf 'ЗЕЛЁНО: перед проверкой отказа make smoke проходит полностью.\n'
else
printf 'ОШИБКА: исходный стенд не проходит make smoke; проверка отказа недостоверна.\n' >&2
exit 1
fi
compose stop prometheus >/dev/null
make --no-print-directory -C "$ROOT_DIR" COMPOSE="${COMPOSE_BIN:-docker compose}" smoke >"$LOG_FILE" 2>&1
status=$?
if [[ "$status" -ne 0 ]] && grep -q 'ОШИБКА: сервис prometheus' "$LOG_FILE"; then
passed=$((passed + 1))
printf 'ЗЕЛЁНО: остановленный prometheus делает make smoke красным и назван в отчёте.\n'
else
printf 'ОШИБКА: make smoke не обнаружил остановленный prometheus; код=%s.\n' "$status" >&2
exit 1
fi
if ! restore_stand; then
printf 'ОШИБКА: не удалось восстановить стенд после проверки.\n' >&2
exit 1
fi
restored=1
if make --no-print-directory -C "$ROOT_DIR" COMPOSE="${COMPOSE_BIN:-docker compose}" smoke >"$LOG_FILE" 2>&1; then
passed=$((passed + 1))
printf 'ЗЕЛЁНО: после восстановления make smoke снова проходит полностью.\n'
else
printf 'ОШИБКА: после восстановления стенд не проходит make smoke.\n' >&2
exit 1
fi
rm -f "$LOG_FILE"
trap - EXIT INT TERM
printf 'ИТОГ: пройдено %d, ошибок 0\n' "$passed"
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly FIXTURE_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$FIXTURE_DIR"
}
trap cleanup EXIT
mkdir -p "$FIXTURE_DIR/bin" "$FIXTURE_DIR/scripts"
cp "$ROOT_DIR/scripts/stand-smoke.sh" "$FIXTURE_DIR/scripts/stand-smoke.sh"
cp "$(type -P false)" "$FIXTURE_DIR/bin/docker"
set +e
output="$(PATH="$FIXTURE_DIR/bin:$PATH" COMPOSE_BIN=false \
"$FIXTURE_DIR/scripts/stand-smoke.sh" 2>&1)"
status=$?
set -e
error_message_status=0
grep -q 'ОШИБКА: compose.yaml или .env.example недоступны для чтения' \
<<<"$output" || error_message_status=$?
green_message_status=0
grep -q 'ЗЕЛЁНО: .env.example совпадает' <<<"$output" || green_message_status=$?
if [[ "$status" -ne 0 ]] && \
[[ "$error_message_status" -eq 0 ]] && \
[[ "$green_message_status" -eq 1 ]]; then
printf 'ЗЕЛЁНО: недоступные compose.yaml и .env.example не проходят статическую проверку.\n'
else
printf 'ОШИБКА: статическая проверка приняла недоступные файлы.\n' >&2
exit 1
fi
cp "$ROOT_DIR/compose.yaml" "$ROOT_DIR/.env.example" "$FIXTURE_DIR/"
set +e
timeout 3s env LC_ALL=ru_RU.UTF-8 PATH="$FIXTURE_DIR/bin:$PATH" COMPOSE_BIN=false \
"$FIXTURE_DIR/scripts/stand-smoke.sh" >/dev/null 2>&1
status=$?
set -e
if [[ "$status" -ne 124 ]]; then
printf 'ЗЕЛЁНО: статическая проверка укладывается в три секунды при русской локали.\n'
else
printf 'ОШИБКА: статическая проверка превысила три секунды при русской локали.\n' >&2
exit 1
fi
grep -Eq '^ # .*подстановки Compose.*\$\$' "$ROOT_DIR/scripts/stand-smoke.sh"
printf 'ЗЕЛЁНО: сложный разбор подстановок Compose пояснён рядом с кодом.\n'
printf 'ИТОГ: пройдено 3, ошибок 0\n'