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