diff --git a/.env.example b/.env.example
index 0a2241f..46d87c0 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/Makefile b/Makefile
index 2593eb6..c93d931 100644
--- a/Makefile
+++ b/Makefile
@@ -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
diff --git a/README.md b/README.md
index 478277f..485bca2 100644
--- a/README.md
+++ b/README.md
@@ -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/).
## Что здесь будет
diff --git a/compose.yaml b/compose.yaml
index 64e711c..b62c31a 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -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:
diff --git a/dags/example_clickstream_hello.py b/dags/example_clickstream_hello.py
new file mode 100644
index 0000000..caa932e
--- /dev/null
+++ b/dags/example_clickstream_hello.py
@@ -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()
diff --git a/docs/adr/0001-stand-services.md b/docs/adr/0001-stand-services.md
new file mode 100644
index 0000000..f645e04
--- /dev/null
+++ b/docs/adr/0001-stand-services.md
@@ -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.
diff --git a/infra/airflow/init.sh b/infra/airflow/init.sh
new file mode 100755
index 0000000..dbc5d1c
--- /dev/null
+++ b/infra/airflow/init.sh
@@ -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"
diff --git a/infra/clickhouse/config.d/prometheus.xml b/infra/clickhouse/config.d/prometheus.xml
new file mode 100644
index 0000000..edd2cef
--- /dev/null
+++ b/infra/clickhouse/config.d/prometheus.xml
@@ -0,0 +1,11 @@
+
+
+
+ /metrics
+ 9363
+ true
+ true
+ true
+ true
+
+
diff --git a/infra/clickhouse/keeper/keeper_config.xml b/infra/clickhouse/keeper/keeper_config.xml
index 50c8df1..18ee799 100644
--- a/infra/clickhouse/keeper/keeper_config.xml
+++ b/infra/clickhouse/keeper/keeper_config.xml
@@ -6,6 +6,15 @@
0.0.0.0
+
+
+ /metrics
+ 9363
+ true
+ true
+ true
+
+
9181
1
@@ -24,4 +33,3 @@
-
diff --git a/infra/grafana/provisioning/datasources/prometheus.yml b/infra/grafana/provisioning/datasources/prometheus.yml
new file mode 100644
index 0000000..00f9915
--- /dev/null
+++ b/infra/grafana/provisioning/datasources/prometheus.yml
@@ -0,0 +1,10 @@
+apiVersion: 1
+
+datasources:
+ - name: Prometheus
+ uid: prometheus
+ type: prometheus
+ access: proxy
+ url: http://prometheus:9090
+ isDefault: true
+ editable: false
diff --git a/infra/postgres/init/10-metadata-databases.sh b/infra/postgres/init/10-metadata-databases.sh
new file mode 100755
index 0000000..449bffd
--- /dev/null
+++ b/infra/postgres/init/10-metadata-databases.sh
@@ -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
diff --git a/infra/prometheus/prometheus.yml b/infra/prometheus/prometheus.yml
new file mode 100644
index 0000000..53abd14
--- /dev/null
+++ b/infra/prometheus/prometheus.yml
@@ -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
diff --git a/infra/superset/Dockerfile b/infra/superset/Dockerfile
new file mode 100644
index 0000000..bd575f0
--- /dev/null
+++ b/infra/superset/Dockerfile
@@ -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
diff --git a/infra/superset/import/databases/clickhouse.yaml b/infra/superset/import/databases/clickhouse.yaml
new file mode 100644
index 0000000..4330e58
--- /dev/null
+++ b/infra/superset/import/databases/clickhouse.yaml
@@ -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
diff --git a/infra/superset/import/metadata.yaml b/infra/superset/import/metadata.yaml
new file mode 100644
index 0000000..33e419e
--- /dev/null
+++ b/infra/superset/import/metadata.yaml
@@ -0,0 +1,3 @@
+version: 1.0.0
+type: Database
+timestamp: '2026-07-30T00:00:00+00:00'
diff --git a/infra/superset/init.sh b/infra/superset/init.sh
new file mode 100755
index 0000000..f5db83e
--- /dev/null
+++ b/infra/superset/init.sh
@@ -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
diff --git a/infra/superset/superset_config.py b/infra/superset/superset_config.py
new file mode 100644
index 0000000..5ae7413
--- /dev/null
+++ b/infra/superset/superset_config.py
@@ -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,
+}
diff --git a/scripts/clickhouse-smoke.sh b/scripts/clickhouse-smoke.sh
index 3de982b..1baa7de 100755
--- a/scripts/clickhouse-smoke.sh
+++ b/scripts/clickhouse-smoke.sh
@@ -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
}
diff --git a/scripts/config-test.sh b/scripts/config-test.sh
new file mode 100755
index 0000000..e937473
--- /dev/null
+++ b/scripts/config-test.sh
@@ -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'
diff --git a/scripts/stand-smoke.sh b/scripts/stand-smoke.sh
new file mode 100755
index 0000000..ae6d0c1
--- /dev/null
+++ b/scripts/stand-smoke.sh
@@ -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 ]]
diff --git a/tests/docs-guards.sh b/tests/docs-guards.sh
new file mode 100755
index 0000000..4b154d1
--- /dev/null
+++ b/tests/docs-guards.sh
@@ -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'
diff --git a/tests/smoke-guards.sh b/tests/smoke-guards.sh
index 17e1a2b..599a342 100755
--- a/tests/smoke-guards.sh
+++ b/tests/smoke-guards.sh
@@ -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
diff --git a/tests/stand-smoke-guards.sh b/tests/stand-smoke-guards.sh
new file mode 100755
index 0000000..54c54cd
--- /dev/null
+++ b/tests/stand-smoke-guards.sh
@@ -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"
diff --git a/tests/stand-smoke-static.sh b/tests/stand-smoke-static.sh
new file mode 100755
index 0000000..000f037
--- /dev/null
+++ b/tests/stand-smoke-static.sh
@@ -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'