Merge pull request 'feat(infra): стенд целиком — Kafka, Airflow 3, Superset и мониторинг одной командой' (#17) from feat/13-stand-full into main
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
+27
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
Executable
+31
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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
|
||||
Executable
+23
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
version: 1.0.0
|
||||
type: Database
|
||||
timestamp: '2026-07-30T00:00:00+00:00'
|
||||
Executable
+31
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Executable
+44
@@ -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'
|
||||
Executable
+613
@@ -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 ]]
|
||||
Executable
+38
@@ -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'
|
||||
@@ -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
|
||||
|
||||
Executable
+68
@@ -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"
|
||||
Executable
+52
@@ -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'
|
||||
Reference in New Issue
Block a user