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_01_TCP_PORT=29000
|
||||||
CLICKHOUSE_02_HTTP_PORT=28124
|
CLICKHOUSE_02_HTTP_PORT=28124
|
||||||
CLICKHOUSE_02_TCP_PORT=29001
|
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:
|
up:
|
||||||
$(COMPOSE) up --detach --wait --wait-timeout 180
|
$(COMPOSE) up --detach --build --wait --wait-timeout 600
|
||||||
|
|
||||||
down:
|
down:
|
||||||
$(COMPOSE) down --remove-orphans
|
$(COMPOSE) down --remove-orphans
|
||||||
@@ -17,8 +17,17 @@ ps:
|
|||||||
logs:
|
logs:
|
||||||
$(COMPOSE) logs --follow
|
$(COMPOSE) logs --follow
|
||||||
|
|
||||||
|
config-test:
|
||||||
|
COMPOSE_BIN="$(COMPOSE)" ./scripts/config-test.sh
|
||||||
|
./tests/stand-smoke-static.sh
|
||||||
|
./tests/docs-guards.sh
|
||||||
|
|
||||||
smoke:
|
smoke:
|
||||||
|
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh
|
||||||
|
|
||||||
|
smoke-cluster:
|
||||||
./scripts/clickhouse-smoke.sh
|
./scripts/clickhouse-smoke.sh
|
||||||
|
|
||||||
smoke-guards:
|
smoke-guards:
|
||||||
./tests/smoke-guards.sh
|
./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).
|
[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).
|
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
|
||||||
Сейчас готов первый элемент каркаса: кластер ClickHouse из двух шардов и
|
Сейчас работают кластер ClickHouse из двух шардов, отдельный
|
||||||
отдельного clickhouse-keeper.
|
clickhouse-keeper, односерверная Kafka в режиме KRaft, Airflow 3.3, Superset,
|
||||||
|
Prometheus, Grafana и общая база Postgres для метаданных.
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
Нужны Docker с Compose и свободные порты `28123`, `28124`, `29000`, `29001`.
|
Нужны Docker с Compose. Полная проверка также использует `curl`, `jq`, `awk`,
|
||||||
Настройки портов и образа лежат в `.env` и не попадают в git:
|
`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
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -22,32 +33,73 @@ make up
|
|||||||
make smoke
|
make smoke
|
||||||
```
|
```
|
||||||
|
|
||||||
`make up` поднимает единственный режим стенда: два шарда ClickHouse по одной
|
Учётные данные Postgres и Grafana применяются при создании их томов.
|
||||||
реплике и отдельный keeper. Команда ждёт здорового состояния всех трёх
|
После первого запуска меняйте их только вместе с `make clean`: команда удалит
|
||||||
контейнеров. Нода `clickhouse-01` служит инициатором DDL и позже будет точкой
|
все локальные данные стенда, а следующий `make up` создаст их с новыми
|
||||||
подключения Airflow. К `clickhouse-02` позже подключится Superset.
|
значениями.
|
||||||
|
|
||||||
`make smoke` одной командой проверяет описание кластера, макросы нод, связь с
|
`make up` собирает локальный образ Superset, поднимает весь стенд и ждёт
|
||||||
keeper, создание `ReplicatedMergeTree` и `Distributed` через `ON CLUSTER`,
|
здорового состояния долгоживущих контейнеров. Одноразовые `airflow-init` и
|
||||||
путь в keeper из макроса, вставку через первую ноду и чтение со второй,
|
`superset-init` завершаются с кодом 0. Первый обновляет схему Airflow,
|
||||||
очередь распределённых DDL и удаление временных таблиц. Скрипт печатает каждый
|
подготавливает администратора и подключение к `clickhouse-01`. Второй обновляет
|
||||||
шаг и зелёный результат по-русски.
|
Superset, создаёт администратора и импортирует подключение к `clickhouse-02`.
|
||||||
|
|
||||||
`make smoke-guards` проверяет саму smoke-команду: аварийную очистку, обработку
|
`make smoke` проверяет согласованность `.env.example` с Compose, зависимости
|
||||||
прерывания, подсказку о незапущенном стенде и окружение keeper.
|
машины, здоровье контейнеров, Kafka через порт машины, три цели Prometheus,
|
||||||
|
источник Grafana, компоненты Airflow, ручной запуск примера DAG, метаданные и
|
||||||
|
подключение Superset. В конце проверка ждёт 20 секунд покоя, печатает общую
|
||||||
|
память контейнеров и падает при превышении 3,4 ГБ. Временный топик Kafka и
|
||||||
|
проверочный запуск DAG удаляются.
|
||||||
|
|
||||||
Остановить контейнеры можно командой `make down`. Для холодного старта с
|
`make smoke-cluster` запускает отдельную глубокую проверку ClickHouse: описание
|
||||||
удалением данных используйте `make clean`.
|
кластера, макросы, связь с keeper, `ReplicatedMergeTree`, `Distributed`, очередь
|
||||||
|
распределённых DDL и очистку временных таблиц.
|
||||||
|
|
||||||
HTTP-интерфейсы доступны только с локальной машины:
|
`make config-test` проверяет Compose, синтаксис Bash и Python и пробельные
|
||||||
|
ошибки в diff без запуска стенда.
|
||||||
|
|
||||||
- нода 1 — `http://127.0.0.1:28123`;
|
`make smoke-guards` сначала проверяет аварийную семантику кластерной проверки,
|
||||||
- нода 2 — `http://127.0.0.1:28124`.
|
а затем останавливает 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` и
|
`default` для запросов `Distributed`. Порты поэтому привязаны к `127.0.0.1` и
|
||||||
не открыты во внешнюю сеть.
|
не открыты во внешнюю сеть.
|
||||||
|
|
||||||
|
Пароли интерфейсов, пароли Postgres, ключи Airflow и Superset, отсутствие
|
||||||
|
пароля ClickHouse и отсутствие проверки доступа у Kafka и Prometheus —
|
||||||
|
намеренно простые и явно ненастоящие настройки локального учебного стенда. Это
|
||||||
|
не пример настройки защиты: не копируйте значения из `.env.example` в рабочую
|
||||||
|
среду. Все опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу
|
||||||
|
не опубликован.
|
||||||
|
|
||||||
В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом
|
В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом
|
||||||
шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология
|
шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология
|
||||||
2×2 намеренно не входят в стенд.
|
2×2 намеренно не входят в стенд.
|
||||||
@@ -65,7 +117,27 @@ HTTP-интерфейсы доступны только с локальной м
|
|||||||
создавал отдельный путь каждого шарда без вписанных вручную значений. Для
|
создавал отдельный путь каждого шарда без вписанных вручную значений. Для
|
||||||
образа зафиксирован точный текущий
|
образа зафиксирован точный текущий
|
||||||
[LTS-выпуск 26.3.17.56](https://github.com/ClickHouse/ClickHouse/releases/tag/v26.3.17.56-lts);
|
[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
|
retries: 30
|
||||||
start_period: 10s
|
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:
|
services:
|
||||||
clickhouse-keeper:
|
clickhouse-keeper:
|
||||||
image: ${CLICKHOUSE_IMAGE:-clickhouse/clickhouse-server:26.3.17.56}
|
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"
|
- "127.0.0.1:${CLICKHOUSE_01_TCP_PORT:-29000}:9000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
|
- ./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
|
- ./infra/clickhouse/config.d/macros-01.xml:/etc/clickhouse-server/config.d/macros.xml:ro
|
||||||
- clickhouse_01_data:/var/lib/clickhouse
|
- clickhouse_01_data:/var/lib/clickhouse
|
||||||
|
|
||||||
@@ -63,10 +106,225 @@ services:
|
|||||||
- "127.0.0.1:${CLICKHOUSE_02_TCP_PORT:-29001}:9000"
|
- "127.0.0.1:${CLICKHOUSE_02_TCP_PORT:-29001}:9000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
|
- ./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
|
- ./infra/clickhouse/config.d/macros-02.xml:/etc/clickhouse-server/config.d/macros.xml:ro
|
||||||
- clickhouse_02_data:/var/lib/clickhouse
|
- 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:
|
volumes:
|
||||||
clickhouse_keeper_data:
|
clickhouse_keeper_data:
|
||||||
clickhouse_01_data:
|
clickhouse_01_data:
|
||||||
clickhouse_02_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>
|
</logger>
|
||||||
<listen_host>0.0.0.0</listen_host>
|
<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>
|
<keeper_server>
|
||||||
<tcp_port>9181</tcp_port>
|
<tcp_port>9181</tcp_port>
|
||||||
<server_id>1</server_id>
|
<server_id>1</server_id>
|
||||||
@@ -24,4 +33,3 @@
|
|||||||
</raft_configuration>
|
</raft_configuration>
|
||||||
</keeper_server>
|
</keeper_server>
|
||||||
</clickhouse>
|
</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
|
if [[ "$status" -ne 0 ]]; then
|
||||||
printf 'Сбой проверки: выполняется быстрая очистка временных таблиц...\n' >&2
|
printf 'Сбой проверки: выполняется быстрая очистка временных таблиц...\n' >&2
|
||||||
fi
|
fi
|
||||||
cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make smoke.\n' >&2
|
cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make smoke-cluster.\n' >&2
|
||||||
exit "$status"
|
exit "$status"
|
||||||
}
|
}
|
||||||
|
|
||||||
on_signal() {
|
on_signal() {
|
||||||
trap - EXIT INT TERM
|
trap - EXIT INT TERM
|
||||||
printf 'Проверка прервана пользователем: выполняется быстрая очистка временных таблиц...\n' >&2
|
printf 'Проверка прервана пользователем: выполняется быстрая очистка временных таблиц...\n' >&2
|
||||||
cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make smoke.\n' >&2
|
cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make smoke-cluster.\n' >&2
|
||||||
exit 130
|
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)"
|
failure_log="$(mktemp)"
|
||||||
started_at="$(date +%s)"
|
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=$!
|
failure_pid=$!
|
||||||
if wait_for_local_table; then
|
if wait_for_local_table; then
|
||||||
compose kill clickhouse-02 >/dev/null
|
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