feat(infra): стенд целиком — Kafka, Airflow 3, Superset и мониторинг одной командой
Зачем: рядом с кластером ClickHouse не хватало остальной платформы, а поднимать её по кускам — значит каждый раз вспоминать порядок. Теперь `make up` даёт стенд целиком, а `make smoke` честно отвечает, работает он или нет. Что: - Kafka в режиме KRaft (без ZooKeeper), Postgres под метаданные, Airflow 3.3 четырьмя сервисами и Superset 6.1 с драйверами ClickHouse; - мониторинг: Prometheus снимает метрики с обеих нод ClickHouse и keeper, Grafana получает подготовленный источник данных; - подключение Airflow ведёт на ноду 1, подключение Superset — на ноду 2: ловушка правильных ошибок, забытый `ON CLUSTER` виден в дашборде сам; - `scripts/stand-smoke.sh` — сквозная проверка из 24 пунктов: топик в Kafka, цели Prometheus, запуск примера DAG через API Airflow, проверка подключения Superset и расход памяти против порога 3,4 ГБ; - `make config-test` — статические ворота: Compose, синтаксис Bash и Python, стражи README; стражи smoke проверяют, что отчёт краснеет на сломанном стенде и зеленеет после восстановления; - решения записаны в `docs/adr/0001-stand-services.md`, состав стенда и порядок работы — в README. Проверка: на чистых томах `make clean` → `cp .env.example .env` → `make up` (1 мин 51 с) → `make smoke` — 24 пройдено, 0 ошибок, 2171 MiB. Перезапуск `make down` → `make up` → `make smoke` — 24/0. Также зелены `make config-test` (3/0 и 5/0), `make smoke-cluster` (8/8) и `make smoke-guards` (3/0 и 3/0). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
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 ]]
|
||||
Reference in New Issue
Block a user