refactor(smoke): срезаны проверки стенда, не служащие менти
- Зачем:
- проверок стало больше, чем продукта, и росли они из критериев приёмки,
а не из учебной ценности (#56).
- Что:
- удалены tests/dag-probes-unit.py, tests/stand-smoke-guards.sh,
tests/stand-smoke-static.sh и tests/smoke-guards.sh вместе с целью
make smoke-guards и запуском юнит-тестов в scripts/config-test.sh.
- из scripts/stand-smoke.sh убран check_env_consistency, туда же переехал
check_keeper_runtime; счёт проверок остался 25.
- в пробниках свёрнуты функции _assert_*, комментарий про отложенный импорт
переписан на причину из документации Airflow и продублирован в test_kafka.
- Проверка:
- make config-test, make up, make smoke, make smoke-cluster.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
COMPOSE ?= docker compose
|
||||
|
||||
.PHONY: up down clean ps logs config-test lint typecheck test docs smoke smoke-cluster smoke-guards
|
||||
.PHONY: up down clean ps logs config-test lint typecheck test docs smoke smoke-cluster
|
||||
|
||||
up:
|
||||
$(COMPOSE) up --detach --build --wait --wait-timeout 600
|
||||
@@ -19,7 +19,6 @@ logs:
|
||||
|
||||
config-test:
|
||||
COMPOSE_BIN="$(COMPOSE)" ./scripts/config-test.sh
|
||||
./tests/stand-smoke-static.sh
|
||||
|
||||
lint:
|
||||
cd generator && uv run ruff check && uv run ruff format --check
|
||||
@@ -39,7 +38,3 @@ smoke:
|
||||
|
||||
smoke-cluster:
|
||||
./scripts/clickhouse-smoke.sh
|
||||
|
||||
smoke-guards:
|
||||
./tests/smoke-guards.sh
|
||||
COMPOSE_BIN="$(COMPOSE)" ./tests/stand-smoke-guards.sh
|
||||
|
||||
@@ -44,6 +44,12 @@ make up
|
||||
make smoke
|
||||
```
|
||||
|
||||
`.env.example` — справочник, а не настройка: в нём перечислены все переменные,
|
||||
которые читает `compose.yaml`, с теми же значениями по умолчанию. Стенд его не
|
||||
читает и на согласованность не проверяет, поэтому расхождение с `compose.yaml`
|
||||
обнаружит только читатель. Меняя подстановку `${VAR:-значение}` в
|
||||
`compose.yaml`, поправьте образец тем же коммитом.
|
||||
|
||||
Учётные данные Postgres и Grafana применяются при создании их томов.
|
||||
После первого запуска меняйте их только вместе с `make clean`: команда удалит
|
||||
все локальные данные стенда, а следующий `make up` создаст их с новыми
|
||||
@@ -56,9 +62,9 @@ make smoke
|
||||
подготавливает администратора и подключение к `clickhouse-01`. Второй обновляет
|
||||
Superset, создаёт администратора и импортирует подключение к `clickhouse-02`.
|
||||
|
||||
`make smoke` проверяет согласованность `.env.example` с Compose, зависимости
|
||||
машины, здоровье контейнеров, Kafka через порт машины, три цели Prometheus,
|
||||
источник Grafana, компоненты Airflow, ручной запуск пробников `test_clickhouse`
|
||||
`make smoke` проверяет зависимости машины, здоровье контейнеров, устройство
|
||||
keeper, Kafka через порт машины, три цели Prometheus, источник Grafana,
|
||||
компоненты Airflow, ручной запуск пробников `test_clickhouse`
|
||||
и `test_kafka`, метаданные и подключение Superset. Первый пробник создаёт
|
||||
таблицы на обеих нодах и читает через `Distributed` на ноде 2 строку из
|
||||
локальной таблицы ноды 1. Второй пишет в Kafka и читает свой маркер. В конце
|
||||
@@ -68,17 +74,28 @@ Superset, создаёт администратора и импортирует
|
||||
Временный топик проверки с машины и запуски DAG удаляются;
|
||||
постоянный топик пробника сохраняется, а старые записи чистит Kafka.
|
||||
|
||||
Про здоровье контейнеров честно будет сказать так: сразу после `make up --wait`
|
||||
эти одиннадцать проверок повторяют то, чего Compose уже дождался, — у каждой
|
||||
долгоживущей службы есть своя `healthcheck`. Оставлены они потому, что первый
|
||||
вопрос к стенду всё равно «всё ли живо», и ответ на него стоит меньше секунды.
|
||||
Устройство keeper — другое дело: он работает от пользователя `clickhouse`, с
|
||||
пределом в 262144 открытых файла и своим каталогом координации. Здоровым он
|
||||
выглядит и без этого, а грабли тут настоящие.
|
||||
|
||||
`make smoke-cluster` запускает отдельную глубокую проверку ClickHouse: описание
|
||||
кластера, макросы, связь с keeper, `ReplicatedMergeTree`, `Distributed`, очередь
|
||||
распределённых DDL и очистку временных таблиц.
|
||||
|
||||
`make config-test` проверяет Compose, синтаксис Bash и Python, малые проверки
|
||||
логики пробников и пробельные ошибки в diff без запуска стенда.
|
||||
`make config-test` проверяет Compose, синтаксис Bash и Python и пробельные
|
||||
ошибки в diff без запуска стенда.
|
||||
|
||||
`make smoke-guards` сначала проверяет аварийную семантику кластерной проверки,
|
||||
а затем удаляет служебную таблицу пробника только на второй ноде и
|
||||
останавливает Prometheus с Kafka. Общая проверка должна назвать Prometheus и
|
||||
оба пробника, после чего завершиться с ошибкой. В конце стенд восстанавливается.
|
||||
Правило, которое стоит держать в голове, правя любую из этих проверок:
|
||||
**проверка, которая не умеет краснеть, бесполезна.** Проверка, никогда не
|
||||
видевшая своей поломки, доказывает только то, что она умеет печатать «ЗЕЛЁНО».
|
||||
Убедиться дешевле всего руками: сломайте то, что она стережёт — остановите
|
||||
`prometheus`, удалите служебную таблицу пробника на второй ноде, — и посмотрите,
|
||||
покраснеет ли прогон и назовёт ли виновника. Не покраснел — проверка не
|
||||
работает, и чинить надо её, а не стенд.
|
||||
|
||||
### Какую проверку когда запускать
|
||||
|
||||
@@ -94,15 +111,8 @@ Superset, создаёт администратора и импортирует
|
||||
между службами, а не отдельные файлы: это интеграционная проверка.
|
||||
- `make smoke-cluster` — около минуты. Одна связь, зато до дна: межнодовое
|
||||
устройство ClickHouse.
|
||||
- `make smoke-guards` — около пяти минут, и отвечает на другой вопрос. Не
|
||||
«работает ли стенд», а «умеют ли проверки падать»: она намеренно ломает стенд
|
||||
и смотрит, покраснеет ли `make smoke` и назовёт ли виновника, потом чинит и
|
||||
убеждается, что стенд снова зелёный. Отсюда и три прогона `make smoke`
|
||||
внутри — до поломки, во время неё и после починки.
|
||||
|
||||
Обычный рабочий цикл — `make config-test` и `make smoke`. `make smoke-guards`
|
||||
нужна тому, кто правит сами проверки или пробники: без неё легко завести
|
||||
проверку, которая зелена всегда.
|
||||
Обычный рабочий цикл — `make config-test` и `make smoke`.
|
||||
|
||||
Остановить контейнеры без удаления данных можно командой `make down`. Для
|
||||
полного сброса с удалением всех именованных томов используйте `make clean`.
|
||||
|
||||
+20
-35
@@ -42,9 +42,10 @@ NODES = (
|
||||
|
||||
|
||||
def _clickhouse_client():
|
||||
# clickhouse_connect стоит только в образе Airflow, а малые проверки грузят
|
||||
# этот модуль обычным интерпретатором, где пакета нет. Импорт верхнего
|
||||
# уровня красит make config-test, поэтому он живёт здесь.
|
||||
# clickhouse_connect импортируется внутри функции, а не наверху файла:
|
||||
# обработчик DAG разбирает этот файл снова и снова, и импорт наверху
|
||||
# оплачивался бы каждым разбором. Тяжёлые импорты Airflow советует
|
||||
# держать внутри задач.
|
||||
import clickhouse_connect
|
||||
|
||||
connection = Connection.get("clickhouse_default")
|
||||
@@ -82,6 +83,8 @@ def _drop_tables(client) -> None:
|
||||
)
|
||||
|
||||
|
||||
# Единственная проверка, вынесенная из задач: её делают обе, до создания таблиц
|
||||
# и после уборки.
|
||||
def _assert_tables_absent(client) -> None:
|
||||
for node_name, source in NODES:
|
||||
remaining = _table_engines(client, source)
|
||||
@@ -91,32 +94,6 @@ def _assert_tables_absent(client) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _assert_tables_created(client) -> None:
|
||||
for node_name, source in NODES:
|
||||
actual_tables = _table_engines(client, source)
|
||||
if actual_tables != EXPECTED_TABLES:
|
||||
raise RuntimeError(
|
||||
f"неверный набор таблиц на {node_name}: {actual_tables}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_marker_path(
|
||||
*,
|
||||
distributed_rows: list[tuple[int, str, str]],
|
||||
write_hostname: str,
|
||||
node_2_hostname: str,
|
||||
marker: str,
|
||||
) -> None:
|
||||
if write_hostname == node_2_hostname:
|
||||
raise RuntimeError("запись и чтение маркера должны выполняться с разных нод")
|
||||
expected_rows = [(1, write_hostname, marker)]
|
||||
if distributed_rows != expected_rows:
|
||||
raise RuntimeError(
|
||||
"нода 2 не прочитала маркер первого шарда через Distributed: "
|
||||
f"{marker}, получено {distributed_rows}"
|
||||
)
|
||||
|
||||
|
||||
@dag(
|
||||
dag_id="test_clickhouse",
|
||||
schedule=None,
|
||||
@@ -157,7 +134,12 @@ def test_clickhouse():
|
||||
)
|
||||
"""
|
||||
)
|
||||
_assert_tables_created(client)
|
||||
for node_name, source in NODES:
|
||||
actual_tables = _table_engines(client, source)
|
||||
if actual_tables != EXPECTED_TABLES:
|
||||
raise RuntimeError(
|
||||
f"неверный набор таблиц на {node_name}: {actual_tables}"
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
@@ -211,11 +193,14 @@ def test_clickhouse():
|
||||
""",
|
||||
parameters={"marker": written["marker"]},
|
||||
).result_rows
|
||||
_assert_marker_path(
|
||||
distributed_rows=distributed_rows,
|
||||
write_hostname=written["hostname"],
|
||||
node_2_hostname=node_2_rows[0][0],
|
||||
marker=written["marker"],
|
||||
if written["hostname"] == node_2_rows[0][0]:
|
||||
raise RuntimeError(
|
||||
"запись и чтение маркера должны выполняться с разных нод"
|
||||
)
|
||||
if distributed_rows != [(1, written["hostname"], written["marker"])]:
|
||||
raise RuntimeError(
|
||||
"нода 2 не прочитала маркер первого шарда через Distributed: "
|
||||
f"{written['marker']}, получено {distributed_rows}"
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
+29
-48
@@ -74,48 +74,6 @@ class RecordAddress(NamedTuple):
|
||||
offset: int
|
||||
|
||||
|
||||
def _assert_all_delivered(undelivered: int, marker: str) -> None:
|
||||
if undelivered:
|
||||
raise RuntimeError(
|
||||
f"Kafka не приняла маркер за {FLUSH_TIMEOUT_SEC} с, "
|
||||
f"не доставлено сообщений {undelivered}: {marker}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_no_delivery_errors(delivery_errors: list[str], marker: str) -> None:
|
||||
if delivery_errors:
|
||||
raise RuntimeError(
|
||||
f"Kafka отказалась принять маркер {marker}: {delivery_errors}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_confirmed_once(addresses: list[RecordAddress], marker: str) -> None:
|
||||
if len(addresses) != 1:
|
||||
raise RuntimeError(
|
||||
f"Kafka подтвердила запись маркера {marker} "
|
||||
f"не одним сообщением: {addresses}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_message_arrived(message, marker: str) -> None:
|
||||
if message is None:
|
||||
raise RuntimeError(
|
||||
f"Kafka молчала {READ_DEADLINE_SEC} с и не вернула маркер: {marker}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_no_read_error(message) -> None:
|
||||
if message.error():
|
||||
raise RuntimeError(f"Kafka вернула ошибку чтения: {message.error()}")
|
||||
|
||||
|
||||
def _assert_marker_matches(message, marker: str) -> None:
|
||||
if message.value() != marker.encode():
|
||||
raise RuntimeError(
|
||||
f"по адресу записи лежит не маркер запуска {marker}: {message.value()!r}"
|
||||
)
|
||||
|
||||
|
||||
@dag(
|
||||
dag_id="test_kafka",
|
||||
schedule=None,
|
||||
@@ -127,6 +85,10 @@ def _assert_marker_matches(message, marker: str) -> None:
|
||||
def test_kafka():
|
||||
@task
|
||||
def write_marker() -> dict[str, str | int]:
|
||||
# confluent_kafka импортируется внутри задачи, а не наверху файла:
|
||||
# обработчик DAG разбирает этот файл снова и снова, и импорт наверху
|
||||
# оплачивался бы каждым разбором. Тяжёлые импорты Airflow советует
|
||||
# держать внутри задач.
|
||||
from confluent_kafka import Producer
|
||||
|
||||
marker = f"{get_current_context()['run_id']}:{uuid.uuid4()}"
|
||||
@@ -158,9 +120,20 @@ def test_kafka():
|
||||
finally:
|
||||
undelivered = producer.flush(FLUSH_TIMEOUT_SEC)
|
||||
|
||||
_assert_all_delivered(undelivered, marker)
|
||||
_assert_no_delivery_errors(delivery_errors, marker)
|
||||
_assert_confirmed_once(addresses, marker)
|
||||
if undelivered:
|
||||
raise RuntimeError(
|
||||
f"Kafka не приняла маркер за {FLUSH_TIMEOUT_SEC} с, "
|
||||
f"не доставлено сообщений {undelivered}: {marker}"
|
||||
)
|
||||
if delivery_errors:
|
||||
raise RuntimeError(
|
||||
f"Kafka отказалась принять маркер {marker}: {delivery_errors}"
|
||||
)
|
||||
if len(addresses) != 1:
|
||||
raise RuntimeError(
|
||||
f"Kafka подтвердила запись маркера {marker} "
|
||||
f"не одним сообщением: {addresses}"
|
||||
)
|
||||
return {
|
||||
"marker": marker,
|
||||
"partition": addresses[0].partition,
|
||||
@@ -190,9 +163,17 @@ def test_kafka():
|
||||
# другого нечем, поэтому у чтения обязан быть крайний срок.
|
||||
while message is None and time.monotonic() < deadline:
|
||||
message = consumer.poll(POLL_TIMEOUT_SEC)
|
||||
_assert_message_arrived(message, marker)
|
||||
_assert_no_read_error(message)
|
||||
_assert_marker_matches(message, marker)
|
||||
if message is None:
|
||||
raise RuntimeError(
|
||||
f"Kafka молчала {READ_DEADLINE_SEC} с и не вернула маркер: {marker}"
|
||||
)
|
||||
if message.error():
|
||||
raise RuntimeError(f"Kafka вернула ошибку чтения: {message.error()}")
|
||||
if message.value() != marker.encode():
|
||||
raise RuntimeError(
|
||||
f"по адресу записи лежит не маркер запуска {marker}: "
|
||||
f"{message.value()!r}"
|
||||
)
|
||||
finally:
|
||||
consumer.close()
|
||||
|
||||
|
||||
@@ -48,20 +48,6 @@ if [[ "${#shell_files[@]}" -eq 0 ]]; then
|
||||
fi
|
||||
bash -n "${shell_files[@]}"
|
||||
PYTHONPYCACHEPREFIX="$CACHE_DIR" uv run --no-project python -m compileall -q "$ROOT_DIR/dags"
|
||||
unit_status=0
|
||||
unit_output="$(
|
||||
PYTHONPYCACHEPREFIX="$CACHE_DIR" \
|
||||
uv run --no-project python "$ROOT_DIR/tests/dag-probes-unit.py" 2>&1
|
||||
)" || unit_status=$?
|
||||
printf '%s\n' "$unit_output"
|
||||
if [[ "$unit_status" -ne 0 ]]; then
|
||||
exit "$unit_status"
|
||||
fi
|
||||
if grep -Eq '^(Ran [0-9]+ tests|OK|FAILED)' <<<"$unit_output" ||
|
||||
! grep -Eq '^ИТОГ: пройдено [0-9]+, ошибок 0$' <<<"$unit_output"; then
|
||||
printf 'ОШИБКА: малые проверки пробников вывели итог не на русском языке.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
git -C "$ROOT_DIR" diff --check
|
||||
|
||||
printf 'ЗЕЛЁНО: Compose, Bash, Python и пробельные ошибки diff проверены.\n'
|
||||
|
||||
+24
-111
@@ -2,8 +2,6 @@
|
||||
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
|
||||
@@ -36,114 +34,6 @@ fail() {
|
||||
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=()
|
||||
@@ -184,6 +74,29 @@ check_container_health() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Keeper — единственная служба, которой мало быть здоровой: она пишет журнал
|
||||
# координации, и если запустить её от root или с чужим каталогом данных, файлы
|
||||
# останутся с неверным владельцем и следующий запуск их не откроет. Предел на
|
||||
# открытые файлы у неё свой: соединений много, и стандартной тысячи не хватает.
|
||||
check_keeper_runtime() {
|
||||
local keeper_user
|
||||
local keeper_nofile
|
||||
local keeper_owner
|
||||
|
||||
keeper_user="$(compose exec -T clickhouse-keeper id -un)"
|
||||
keeper_nofile="$(compose exec -T clickhouse-keeper \
|
||||
awk '$1 == "Max" && $2 == "open" && $3 == "files" {print $4}' /proc/1/limits)"
|
||||
keeper_owner="$(compose exec -T clickhouse-keeper \
|
||||
stat -c '%U:%G' /var/lib/clickhouse/coordination)"
|
||||
if [[ "$keeper_user" == 'clickhouse' ]] && \
|
||||
[[ "$keeper_nofile" -ge 262144 ]] && \
|
||||
[[ "$keeper_owner" == 'clickhouse:clickhouse' ]]; then
|
||||
pass 'keeper работает от clickhouse с nofile 262144 и своим каталогом данных'
|
||||
else
|
||||
fail "неверное окружение keeper: пользователь=${keeper_user}, nofile=${keeper_nofile}, владелец каталога=${keeper_owner}"
|
||||
fi
|
||||
}
|
||||
|
||||
published_port() {
|
||||
local binding
|
||||
local service="$1"
|
||||
@@ -627,11 +540,11 @@ check_containers_survived() {
|
||||
fi
|
||||
}
|
||||
|
||||
check_env_consistency
|
||||
if check_host_dependencies; then
|
||||
for service in "${LONG_LIVED_SERVICES[@]}"; do
|
||||
check_container_health "$service"
|
||||
done
|
||||
check_keeper_runtime
|
||||
check_kafka_from_host
|
||||
check_prometheus_targets
|
||||
check_grafana_datasource
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
"""Малые проверки логики пробников ClickHouse и Kafka без запуска Airflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DeclaredTask:
|
||||
"""Заглушка объявленной задачи: держит только цепочку через `>>`."""
|
||||
|
||||
def __rshift__(self, other):
|
||||
return other
|
||||
|
||||
|
||||
def load_clickhouse_dag_tasks():
|
||||
airflow_module = types.ModuleType("airflow")
|
||||
sdk_module = types.ModuleType("airflow.sdk")
|
||||
captured_tasks = {}
|
||||
|
||||
def dag(**_kwargs):
|
||||
def decorate(function):
|
||||
return function
|
||||
|
||||
return decorate
|
||||
|
||||
def task(function=None, **_kwargs):
|
||||
def capture(target):
|
||||
captured_tasks[target.__name__] = target
|
||||
|
||||
def declare_task(*_args, **_kwargs):
|
||||
return DeclaredTask()
|
||||
|
||||
return declare_task
|
||||
|
||||
return capture(function) if function is not None else capture
|
||||
|
||||
sdk_module.Connection = object
|
||||
sdk_module.dag = dag
|
||||
sdk_module.get_current_context = lambda: {}
|
||||
sdk_module.task = task
|
||||
airflow_module.sdk = sdk_module
|
||||
sys.modules["airflow"] = airflow_module
|
||||
sys.modules["airflow.sdk"] = sdk_module
|
||||
|
||||
dag_path = Path(__file__).resolve().parents[1] / "dags" / "test_clickhouse.py"
|
||||
spec = importlib.util.spec_from_file_location("test_clickhouse_dag", dag_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("не удалось загрузить модуль пробника ClickHouse")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module, captured_tasks
|
||||
|
||||
|
||||
def load_kafka_dag_tasks():
|
||||
airflow_module = types.ModuleType("airflow")
|
||||
sdk_module = types.ModuleType("airflow.sdk")
|
||||
captured_tasks = {}
|
||||
|
||||
def dag(**_kwargs):
|
||||
def decorate(function):
|
||||
return function
|
||||
|
||||
return decorate
|
||||
|
||||
def task(function):
|
||||
captured_tasks[function.__name__] = function
|
||||
|
||||
def declare_task(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
return declare_task
|
||||
|
||||
sdk_module.dag = dag
|
||||
sdk_module.get_current_context = lambda: {"run_id": "unit-test"}
|
||||
sdk_module.task = task
|
||||
airflow_module.sdk = sdk_module
|
||||
sys.modules["airflow"] = airflow_module
|
||||
sys.modules["airflow.sdk"] = sdk_module
|
||||
|
||||
dag_path = Path(__file__).resolve().parents[1] / "dags" / "test_kafka.py"
|
||||
spec = importlib.util.spec_from_file_location("test_kafka_dag", dag_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("не удалось загрузить модуль пробника Kafka")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return captured_tasks
|
||||
|
||||
|
||||
class QueryResult:
|
||||
def __init__(self, result_rows: list[tuple]) -> None:
|
||||
self.result_rows = result_rows
|
||||
|
||||
|
||||
class RecordingClient:
|
||||
"""Клиент ClickHouse, который запоминает запросы и отвечает заготовкой."""
|
||||
|
||||
def __init__(self, remaining_tables: list[tuple[str, str]] | None = None) -> None:
|
||||
self.commands: list[str] = []
|
||||
self.queries: list[str] = []
|
||||
self.closed = False
|
||||
self._remaining_tables = remaining_tables or []
|
||||
|
||||
def command(self, sql: str) -> None:
|
||||
self.commands.append(" ".join(sql.split()))
|
||||
|
||||
def query(self, sql: str, parameters=None) -> QueryResult:
|
||||
self.queries.append(" ".join(sql.split()))
|
||||
return QueryResult(list(self._remaining_tables))
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class ClickHouseProbeTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.module, cls.tasks = load_clickhouse_dag_tasks()
|
||||
|
||||
def run_cleanup_task(self, client: RecordingClient) -> None:
|
||||
original_client_factory = self.module._clickhouse_client
|
||||
self.module._clickhouse_client = lambda: client
|
||||
try:
|
||||
self.tasks["cleanup_tables"]()
|
||||
finally:
|
||||
self.module._clickhouse_client = original_client_factory
|
||||
|
||||
def test_marker_path_requires_different_nodes_and_first_shard(self) -> None:
|
||||
marker = "свой-маркер"
|
||||
self.module._assert_marker_path(
|
||||
distributed_rows=[(1, "clickhouse-01-host", marker)],
|
||||
write_hostname="clickhouse-01-host",
|
||||
node_2_hostname="clickhouse-02-host",
|
||||
marker=marker,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "разных нод"):
|
||||
self.module._assert_marker_path(
|
||||
distributed_rows=[(2, "clickhouse-02-host", marker)],
|
||||
write_hostname="clickhouse-02-host",
|
||||
node_2_hostname="clickhouse-02-host",
|
||||
marker=marker,
|
||||
)
|
||||
with self.assertRaisesRegex(RuntimeError, "первого шарда"):
|
||||
self.module._assert_marker_path(
|
||||
distributed_rows=[(2, "clickhouse-01-host", marker)],
|
||||
write_hostname="clickhouse-01-host",
|
||||
node_2_hostname="clickhouse-02-host",
|
||||
marker=marker,
|
||||
)
|
||||
|
||||
def test_cleanup_drops_tables_and_checks_both_nodes(self) -> None:
|
||||
client = RecordingClient()
|
||||
|
||||
self.run_cleanup_task(client)
|
||||
|
||||
dropped = {
|
||||
table
|
||||
for table in (self.module.LOCAL_TABLE, self.module.DISTRIBUTED_TABLE)
|
||||
if any(
|
||||
command.startswith(f"DROP TABLE IF EXISTS default.{table} ")
|
||||
for command in client.commands
|
||||
)
|
||||
}
|
||||
self.assertEqual(
|
||||
dropped, {self.module.LOCAL_TABLE, self.module.DISTRIBUTED_TABLE}
|
||||
)
|
||||
self.assertEqual(len(client.queries), len(self.module.NODES))
|
||||
self.assertTrue(client.closed)
|
||||
|
||||
def test_cleanup_closes_client_when_tables_survive(self) -> None:
|
||||
client = RecordingClient(remaining_tables=[("airflow_probe_local", "Log")])
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "служебные таблицы остались"):
|
||||
self.run_cleanup_task(client)
|
||||
|
||||
self.assertTrue(client.closed)
|
||||
|
||||
|
||||
DELIVERED_PARTITION = 3
|
||||
DELIVERED_OFFSET = 42
|
||||
|
||||
|
||||
class StubMessage:
|
||||
"""Сообщение Kafka в том объёме, в каком его читает пробник."""
|
||||
|
||||
def __init__(self, value: bytes) -> None:
|
||||
self._value = value
|
||||
|
||||
def partition(self) -> int:
|
||||
return DELIVERED_PARTITION
|
||||
|
||||
def offset(self) -> int:
|
||||
return DELIVERED_OFFSET
|
||||
|
||||
def error(self):
|
||||
return None
|
||||
|
||||
def value(self) -> bytes:
|
||||
return self._value
|
||||
|
||||
|
||||
class StubTopicPartition:
|
||||
def __init__(self, topic: str, partition: int, offset: int) -> None:
|
||||
self.topic = topic
|
||||
self.partition = partition
|
||||
self.offset = offset
|
||||
|
||||
|
||||
def install_kafka_stub(produce_error: str | None = None):
|
||||
"""Ставит заглушку `confluent_kafka` и возвращает журналы её клиентов.
|
||||
|
||||
Продюсер подтверждает доставку сразу и по известному адресу, консьюмер
|
||||
отдаёт записанное с первого опроса. Если задан `produce_error`, запись
|
||||
падает — так проверяется, что продюсер закрывается и на пути отказа.
|
||||
"""
|
||||
producers = []
|
||||
consumers = []
|
||||
|
||||
class Producer:
|
||||
def __init__(self, _config) -> None:
|
||||
self.written = b""
|
||||
self.flushed = False
|
||||
producers.append(self)
|
||||
|
||||
def produce(self, _topic, key=None, value=None, on_delivery=None) -> None:
|
||||
if produce_error is not None:
|
||||
raise RuntimeError(produce_error)
|
||||
self.written = value
|
||||
on_delivery(None, StubMessage(value))
|
||||
|
||||
def flush(self, _timeout) -> int:
|
||||
self.flushed = True
|
||||
return 0
|
||||
|
||||
class Consumer:
|
||||
def __init__(self, _config) -> None:
|
||||
self.assigned = []
|
||||
self.closed = False
|
||||
consumers.append(self)
|
||||
|
||||
def assign(self, partitions) -> None:
|
||||
self.assigned = partitions
|
||||
|
||||
def poll(self, _timeout):
|
||||
return StubMessage(producers[-1].written)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
kafka_module = types.ModuleType("confluent_kafka")
|
||||
kafka_module.Consumer = Consumer
|
||||
kafka_module.Producer = Producer
|
||||
kafka_module.TopicPartition = StubTopicPartition
|
||||
sys.modules["confluent_kafka"] = kafka_module
|
||||
return producers, consumers
|
||||
|
||||
|
||||
class KafkaProbeTests(unittest.TestCase):
|
||||
def test_producer_is_closed_when_write_fails(self) -> None:
|
||||
producers, _ = install_kafka_stub(produce_error="брокер недоступен")
|
||||
tasks = load_kafka_dag_tasks()
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "брокер недоступен"):
|
||||
tasks["write_marker"]()
|
||||
|
||||
self.assertEqual(len(producers), 1)
|
||||
self.assertTrue(producers[0].flushed)
|
||||
|
||||
def test_read_goes_to_the_address_broker_returned(self) -> None:
|
||||
_, consumers = install_kafka_stub()
|
||||
tasks = load_kafka_dag_tasks()
|
||||
|
||||
written = tasks["write_marker"]()
|
||||
tasks["read_marker"](written)
|
||||
|
||||
self.assertEqual(
|
||||
(written["partition"], written["offset"]),
|
||||
(DELIVERED_PARTITION, DELIVERED_OFFSET),
|
||||
)
|
||||
self.assertEqual(len(consumers), 1)
|
||||
self.assertEqual(len(consumers[0].assigned), 1)
|
||||
assigned = consumers[0].assigned[0]
|
||||
self.assertEqual(assigned.partition, DELIVERED_PARTITION)
|
||||
self.assertEqual(assigned.offset, DELIVERED_OFFSET)
|
||||
self.assertTrue(consumers[0].closed)
|
||||
|
||||
|
||||
def run_tests() -> int:
|
||||
suite = unittest.defaultTestLoader.loadTestsFromModule(sys.modules[__name__])
|
||||
result = unittest.TestResult()
|
||||
suite.run(result)
|
||||
|
||||
problems = result.failures + result.errors
|
||||
for test, details in problems:
|
||||
print(f"ОШИБКА: {test.id()}", file=sys.stderr)
|
||||
print(details, file=sys.stderr)
|
||||
passed = result.testsRun - len(problems) - len(result.skipped)
|
||||
print(f"ИТОГ: пройдено {passed}, ошибок {len(problems)}")
|
||||
return 0 if result.wasSuccessful() else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(run_tests())
|
||||
@@ -1,139 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly LOCAL_TABLE="smoke_replicated_local"
|
||||
readonly DISTRIBUTED_TABLE="smoke_distributed"
|
||||
passed=0
|
||||
failed=0
|
||||
|
||||
compose() {
|
||||
docker compose --project-directory "$ROOT_DIR" "$@"
|
||||
}
|
||||
|
||||
query() {
|
||||
local service="$1"
|
||||
local sql="$2"
|
||||
compose exec -T "$service" clickhouse-client --query "$sql"
|
||||
}
|
||||
|
||||
pass() {
|
||||
passed=$((passed + 1))
|
||||
printf 'ЗЕЛЁНО: %s.\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
failed=$((failed + 1))
|
||||
printf 'ОШИБКА: %s.\n' "$1" >&2
|
||||
}
|
||||
|
||||
wait_for_local_table() {
|
||||
local attempt
|
||||
for attempt in $(seq 1 100); do
|
||||
if [[ "$(query clickhouse-01 "SELECT count() FROM system.tables WHERE database = 'default' AND name = '${LOCAL_TABLE}' FORMAT TSVRaw" 2>/dev/null || true)" == '1' ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.05
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
restore_stand() {
|
||||
make --no-print-directory -C "$ROOT_DIR" up >/dev/null
|
||||
query clickhouse-01 "DROP TABLE IF EXISTS default.${DISTRIBUTED_TABLE} ON CLUSTER clickstream_cluster SYNC" >/dev/null 2>&1 || true
|
||||
query clickhouse-01 "DROP TABLE IF EXISTS default.${LOCAL_TABLE} ON CLUSTER clickstream_cluster SYNC" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
check_failure_guards() {
|
||||
local failure_log
|
||||
local failure_pid
|
||||
local failure_status
|
||||
local failure_elapsed
|
||||
local interrupt_log
|
||||
local interrupt_pid
|
||||
local interrupt_status
|
||||
local remaining_tables
|
||||
local started_at
|
||||
local bounded_failure_ok=0
|
||||
local interrupt_ok=0
|
||||
interrupt_status=999
|
||||
remaining_tables='не проверено'
|
||||
|
||||
failure_log="$(mktemp)"
|
||||
started_at="$(date +%s)"
|
||||
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
|
||||
wait "$failure_pid"
|
||||
failure_status=$?
|
||||
failure_elapsed=$(( $(date +%s) - started_at ))
|
||||
if [[ "$failure_status" -eq 2 ]] && [[ "$failure_elapsed" -lt 20 ]]; then
|
||||
bounded_failure_ok=1
|
||||
fi
|
||||
else
|
||||
kill -TERM -- "-$failure_pid" >/dev/null 2>&1 || true
|
||||
wait "$failure_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -f "$failure_log"
|
||||
|
||||
restore_stand
|
||||
|
||||
interrupt_log="$(mktemp)"
|
||||
setsid env --default-signal=INT,TERM "$ROOT_DIR/scripts/clickhouse-smoke.sh" >"$interrupt_log" 2>&1 &
|
||||
interrupt_pid=$!
|
||||
if wait_for_local_table; then
|
||||
kill -INT -- "-$interrupt_pid"
|
||||
wait "$interrupt_pid"
|
||||
interrupt_status=$?
|
||||
remaining_tables="$(query clickhouse-01 "SELECT count() FROM clusterAllReplicas('clickstream_cluster', system.tables) WHERE database = 'default' AND name IN ('${LOCAL_TABLE}', '${DISTRIBUTED_TABLE}') FORMAT TSVRaw")"
|
||||
if [[ "$interrupt_status" -eq 130 ]] && [[ "$remaining_tables" == '0' ]]; then
|
||||
interrupt_ok=1
|
||||
fi
|
||||
else
|
||||
kill -TERM -- "-$interrupt_pid" >/dev/null 2>&1 || true
|
||||
wait "$interrupt_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -f "$interrupt_log"
|
||||
|
||||
if [[ "$bounded_failure_ok" -eq 1 ]] && [[ "$interrupt_ok" -eq 1 ]]; then
|
||||
pass 'аварийная очистка ограничена по времени, SIGINT возвращает 130 и удаляет временные таблицы'
|
||||
else
|
||||
fail "нарушена аварийная семантика smoke: bounded=${bounded_failure_ok}, interrupt=${interrupt_ok}, status=${interrupt_status}, tables=${remaining_tables}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_keeper_runtime() {
|
||||
local keeper_user
|
||||
local keeper_nofile
|
||||
local keeper_owner
|
||||
keeper_user="$(compose exec -T clickhouse-keeper id -un)"
|
||||
keeper_nofile="$(compose exec -T clickhouse-keeper awk '$1 == "Max" && $2 == "open" && $3 == "files" {print $4}' /proc/1/limits)"
|
||||
keeper_owner="$(compose exec -T clickhouse-keeper stat -c '%U:%G' /var/lib/clickhouse/coordination)"
|
||||
if [[ "$keeper_user" == 'clickhouse' ]] && [[ "$keeper_nofile" -ge 262144 ]] && [[ "$keeper_owner" == 'clickhouse:clickhouse' ]]; then
|
||||
pass 'keeper работает от clickhouse с nofile 262144 и своим каталогом данных'
|
||||
else
|
||||
fail "неверное окружение keeper: user=${keeper_user}, nofile=${keeper_nofile}, owner=${keeper_owner}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_preflight_hint() {
|
||||
local output
|
||||
local status
|
||||
compose kill clickhouse-02 >/dev/null
|
||||
output="$("$ROOT_DIR/scripts/clickhouse-smoke.sh" 2>&1)"
|
||||
status=$?
|
||||
if [[ "$status" -ne 0 ]] && grep -q 'выполните make up' <<<"$output"; then
|
||||
pass 'неполный стенд получает русскую подсказку выполнить make up'
|
||||
else
|
||||
fail 'нет русской подсказки для незапущенного стенда'
|
||||
fi
|
||||
make --no-print-directory -C "$ROOT_DIR" up >/dev/null
|
||||
}
|
||||
|
||||
check_failure_guards
|
||||
check_keeper_runtime
|
||||
check_preflight_hint
|
||||
|
||||
printf 'ИТОГ: пройдено %d, ошибок %d\n' "$passed" "$failed"
|
||||
[[ "$failed" -eq 0 ]]
|
||||
@@ -1,193 +0,0 @@
|
||||
#!/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)"
|
||||
readonly CLICKHOUSE_LOCAL_TABLE="airflow_probe_local"
|
||||
readonly CLICKHOUSE_DISTRIBUTED_TABLE="airflow_probe_distributed"
|
||||
restored=0
|
||||
passed=0
|
||||
red_smoke_pid=''
|
||||
|
||||
compose() {
|
||||
"${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" "$@"
|
||||
}
|
||||
|
||||
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##*:}"
|
||||
}
|
||||
|
||||
query_node_2() {
|
||||
local sql="$1"
|
||||
local port
|
||||
port="$(published_port clickhouse-02 8123)"
|
||||
curl -sf --max-time 2 --data-binary "$sql" "http://127.0.0.1:${port}/"
|
||||
}
|
||||
|
||||
break_clickhouse_probe() {
|
||||
local deadline
|
||||
local table_state
|
||||
|
||||
deadline=$((EPOCHSECONDS + 60))
|
||||
printf 'Ожидание служебных таблиц на ноде 2, предел 60 секунд...\n'
|
||||
while [[ "$EPOCHSECONDS" -lt "$deadline" ]]; do
|
||||
table_state="$(query_node_2 "
|
||||
SELECT
|
||||
countIf(name = '${CLICKHOUSE_LOCAL_TABLE}'),
|
||||
countIf(name = '${CLICKHOUSE_DISTRIBUTED_TABLE}')
|
||||
FROM system.tables
|
||||
WHERE database = 'default'
|
||||
AND name IN (
|
||||
'${CLICKHOUSE_LOCAL_TABLE}',
|
||||
'${CLICKHOUSE_DISTRIBUTED_TABLE}'
|
||||
)
|
||||
FORMAT TSV
|
||||
" 2>/dev/null || true)"
|
||||
if [[ "$table_state" == $'1\t1' ]]; then
|
||||
query_node_2 \
|
||||
"DROP TABLE IF EXISTS default.${CLICKHOUSE_LOCAL_TABLE} SYNC" \
|
||||
>/dev/null
|
||||
table_state="$(query_node_2 "
|
||||
SELECT
|
||||
countIf(name = '${CLICKHOUSE_LOCAL_TABLE}'),
|
||||
countIf(name = '${CLICKHOUSE_DISTRIBUTED_TABLE}')
|
||||
FROM system.tables
|
||||
WHERE database = 'default'
|
||||
AND name IN (
|
||||
'${CLICKHOUSE_LOCAL_TABLE}',
|
||||
'${CLICKHOUSE_DISTRIBUTED_TABLE}'
|
||||
)
|
||||
FORMAT TSV
|
||||
" 2>/dev/null || true)"
|
||||
if [[ "$table_state" == $'0\t1' ]]; then
|
||||
printf 'Служебная локальная таблица удалена только на ноде 2.\n'
|
||||
return
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
sleep 0.05
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Пробник разбит на четыре задачи, и упасть может любая из них: поломка на ноде
|
||||
# 2 видна и проверке набора таблиц, и чтению маркера. Поэтому берём последний
|
||||
# каталог запуска целиком и ищем образец по журналам всех его задач.
|
||||
clickhouse_break_is_reported() {
|
||||
compose exec -T airflow-scheduler bash -ceu '
|
||||
latest="$(
|
||||
find /opt/airflow/logs/dag_id=test_clickhouse \
|
||||
-mindepth 1 -maxdepth 1 -type d -name "run_id=*" -printf "%T@ %p\n" |
|
||||
sort -nr |
|
||||
head -n 1
|
||||
)"
|
||||
latest="${latest#* }"
|
||||
test -n "$latest"
|
||||
grep -Eqr --include="attempt=1.log" \
|
||||
"неверный набор таблиц на ноде 2|Unknown table expression identifier '\''default.airflow_probe_local'\''" \
|
||||
"$latest"
|
||||
'
|
||||
}
|
||||
|
||||
probe_failure_is_reported() {
|
||||
local dag_id="$1"
|
||||
grep -Eq \
|
||||
"ОШИБКА: (пробник ${dag_id}|Airflow не показал пробник ${dag_id})" \
|
||||
"$LOG_FILE"
|
||||
}
|
||||
|
||||
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
|
||||
if [[ -n "$red_smoke_pid" ]]; then
|
||||
kill -TERM -- "-$red_smoke_pid" >/dev/null 2>&1 || true
|
||||
wait "$red_smoke_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
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
|
||||
setsid make --no-print-directory -C "$ROOT_DIR" \
|
||||
COMPOSE="${COMPOSE_BIN:-docker compose}" smoke >"$LOG_FILE" 2>&1 &
|
||||
red_smoke_pid=$!
|
||||
if ! break_clickhouse_probe; then
|
||||
printf 'ОШИБКА: не удалось удалить служебную таблицу только на ноде 2.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
compose stop kafka >/dev/null
|
||||
wait "$red_smoke_pid"
|
||||
status=$?
|
||||
red_smoke_pid=''
|
||||
|
||||
red_path_ok=1
|
||||
if [[ "$status" -eq 0 ]]; then
|
||||
printf 'ОШИБКА: make smoke остался зелёным после внесённых поломок.\n' >&2
|
||||
red_path_ok=0
|
||||
fi
|
||||
if ! grep -q 'ОШИБКА: сервис prometheus' "$LOG_FILE"; then
|
||||
printf 'ОШИБКА: make smoke не назвал остановленный prometheus.\n' >&2
|
||||
red_path_ok=0
|
||||
fi
|
||||
if ! probe_failure_is_reported test_clickhouse; then
|
||||
printf 'ОШИБКА: make smoke не назвал пробник test_clickhouse.\n' >&2
|
||||
red_path_ok=0
|
||||
fi
|
||||
if ! probe_failure_is_reported test_kafka; then
|
||||
printf 'ОШИБКА: make smoke не назвал пробник test_kafka.\n' >&2
|
||||
red_path_ok=0
|
||||
fi
|
||||
if ! clickhouse_break_is_reported; then
|
||||
printf 'ОШИБКА: журнал test_clickhouse не связал отказ с удалённой таблицей на ноде 2.\n' >&2
|
||||
red_path_ok=0
|
||||
fi
|
||||
|
||||
if [[ "$red_path_ok" -eq 1 ]]; then
|
||||
passed=$((passed + 1))
|
||||
printf 'ЗЕЛЁНО: удаление таблицы на ноде 2 и остановка prometheus с kafka делают make smoke красным; оба пробника названы в отчёте.\n'
|
||||
else
|
||||
printf 'ОШИБКА: проверка красного пути завершилась с кодом make smoke %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"
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/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
|
||||
|
||||
printf 'ИТОГ: пройдено 2, ошибок 0\n'
|
||||
Reference in New Issue
Block a user