test(#21): проверка AC на Docker-хосте и статические инварианты compose

- verify-dev-stand.sh (`make dev-verify`): AC-1 (health 200, различает
  api=real/stub), AC-2 (маркеры в /config и /data до и после restart и
  --force-recreate, включая проверку на хосте), проверка монтирований и
  health контейнера, JSON-отчёт; коды 0/1/2/3 — ok/regression/blocked-#31/
  blocked-env;
- tests/deploy/validate_compose.py — проверки без Docker: контейнерный порт
  8080, тома /config и /data, healthcheck /api/v1/health, NET_ADMIN и
  /dev/net/tun, ip_forward, лимит логов, privileged netns-раннер и DEV-ONLY
  заглушка. Флаг --require-dockerfile для интеграционного дерева.

Refs #21
Refs #15
This commit is contained in:
2026-10-21 23:49:43 +03:00
parent da1c750297
commit 7d46df5ede
2 changed files with 412 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env bash
# verify-dev-stand.sh — проверка acceptance criteria задачи #21 на Docker-хосте.
#
# Запускать из корня репозитория на машине с Docker:
# bash deploy/verify-dev-stand.sh # реальный API (после мержа #31)
# bash deploy/verify-dev-stand.sh --stub # проверка плумбинга до #31 (health-заглушка)
#
# Что проверяет:
# AC-1: `curl http://<host>:<port>/api/v1/health` -> 200;
# AC-2: правки в /config и /data переживают `docker compose restart`
# (и дополнительно пересоздание контейнера);
# пласт: монтирования /config и /data указывают на ожидаемый каталог хоста,
# контейнер healthy, образ и compose-файлы на месте.
#
# Коды возврата:
# 0 — AC-1 и AC-2 выполнены;
# 1 — AC нарушены (регрессия стенда);
# 2 — AC-1 заблокирован: API ещё не реализован (#31), стенд при этом поднят;
# 3 — заблокировано окружением (нет docker/compose/файлов).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_DEV="$ROOT/deploy/docker-compose.dev.yml"
COMPOSE_STUB="$ROOT/deploy/docker-compose.stub.yml"
STUB=0
DO_UP=1
DO_BUILD=1
KEEP_STAND=0
HOST="127.0.0.1"
PORT="${AWG_DASH_PORT:-8180}"
HOST_DIR="${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}"
JSON_OUT=""
HEALTH_PATH="/api/v1/health"
usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
while [ $# -gt 0 ]; do
case "$1" in
--stub) STUB=1 ;;
--no-up) DO_UP=0 ;;
--skip-build) DO_BUILD=0 ;;
--keep) KEEP_STAND=1 ;;
--host) HOST="${2:?}"; shift ;;
--port) PORT="${2:?}"; shift ;;
--host-dir) HOST_DIR="${2:?}"; shift ;;
--compose) COMPOSE_DEV="${2:?}"; shift ;;
--json) JSON_OUT="${2:?}"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "неизвестная опция: $1" >&2; exit 3 ;;
esac
shift
done
fatal_env() { printf '[verify][blocked] %s\n' "$*" >&2; exit 3; }
say() { printf '[verify] %s\n' "$*"; }
ok() { printf '[verify][ ok ] %s\n' "$*"; }
bad() { printf '[verify][fail] %s\n' "$*" >&2; }
# --- предусловия -------------------------------------------------------------
command -v docker >/dev/null 2>&1 || fatal_env "нет docker в PATH"
docker compose version >/dev/null 2>&1 || fatal_env "нет плагина 'docker compose'"
[ -f "$COMPOSE_DEV" ] || fatal_env "нет файла $COMPOSE_DEV"
COMPOSE_ARGS=(-f "$COMPOSE_DEV")
if [ "$STUB" -eq 1 ]; then
[ -f "$COMPOSE_STUB" ] || fatal_env "нет файла $COMPOSE_STUB"
COMPOSE_ARGS+=(-f "$COMPOSE_STUB")
fi
compose() { docker compose "${COMPOSE_ARGS[@]}" "$@"; }
AC1="not_run"
AC2="not_run"
API_MODE="unknown"
NOTES=()
FAILURES=0
# --- поднятие стенда ---------------------------------------------------------
if [ "$DO_UP" -eq 1 ]; then
UP_ARGS=(up -d)
[ "$DO_BUILD" -eq 1 ] && UP_ARGS+=(--build)
say "docker compose ${COMPOSE_ARGS[*]} ${UP_ARGS[*]}"
compose "${UP_ARGS[@]}" || fatal_env "не удалось поднять dev-стенд (см. вывод docker compose)"
fi
compose ps >/dev/null 2>&1 || fatal_env "compose project не поднят"
CID="$(compose ps -q awg-dash | head -n 1)"
[ -n "$CID" ] || fatal_env "контейнер awg-dash не найден в compose project"
wait_health() {
local tries="${1:-30}" code i
for ((i = 1; i <= tries; i++)); do
code="$(curl -s -o /dev/null -m 3 -w '%{http_code}' "http://${HOST}:${PORT}${HEALTH_PATH}" || true)"
[ "$code" = "200" ] && return 0
sleep 2
done
return 1
}
# --- AC-1: health 200 --------------------------------------------------------
BODY="$(curl -s -m 3 "http://${HOST}:${PORT}${HEALTH_PATH}" || true)"
if wait_health 20; then
BODY="$(curl -s -m 3 "http://${HOST}:${PORT}${HEALTH_PATH}" || true)"
if printf '%s' "$BODY" | grep -q '"stub": *true'; then
API_MODE="stub"
NOTES+=("health отвечает заглушка (#31): реальный API ещё не смёржен")
ok "AC-1: http://${HOST}:${PORT}${HEALTH_PATH} -> 200 (api=stub)"
else
API_MODE="real"
ok "AC-1: http://${HOST}:${PORT}${HEALTH_PATH} -> 200 (api=real)"
fi
AC1="pass"
else
CODE="$(curl -s -o /dev/null -m 3 -w '%{http_code}' "http://${HOST}:${PORT}${HEALTH_PATH}" || echo "000")"
say "health-check вернул ${CODE}; последние логи контейнера:"
compose logs --tail=15 awg-dash || true
if [ "$STUB" -eq 0 ]; then
AC1="blocked"
NOTES+=("AC-1 заблокирован: FastAPI-каркас (#31) ещё не реализован — /api/v1/health не отвечает")
printf '[verify][blocked] AC-1 требует #31 (см. deploy/dev-stand.md)\n' >&2
else
AC1="fail"
FAILURES=$((FAILURES + 1))
bad "AC-1: даже с заглушкой health не 200 (код ${CODE})"
fi
fi
# --- AC-2: состояние переживает restart --------------------------------------
if [ "$AC1" = "blocked" ]; then
NOTES+=("AC-2 проверяем без API: маркерные файлы в /config и /data")
fi
STAMP="$(date +%s)"
MARKER_TEXT="awg-dash-ac2-${STAMP}"
say "AC-2: пишу маркеры в /config и /data и перезапускаю контейнер"
if compose exec -T awg-dash sh -c "printf '%s\n' '$MARKER_TEXT' > /config/.ac2-marker && printf '%s\n' '$MARKER_TEXT' > /data/.ac2-marker"; then
# 1) restart (как в AC)
compose restart awg-dash >/dev/null
if [ "$AC1" = "pass" ]; then wait_health 20 || NOTES+=("после restart health не поднялся за 40с"); fi
CFG_AFTER="$(compose exec -T awg-dash cat /config/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)"
DATA_AFTER="$(compose exec -T awg-dash cat /data/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)"
# 2) пересоздание контейнера (сильнее, чем restart)
compose up -d --force-recreate >/dev/null
if [ "$AC1" = "pass" ]; then wait_health 20 || NOTES+=("после пересоздания health не поднялся за 40с"); fi
CFG_RECREATE="$(compose exec -T awg-dash cat /config/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)"
DATA_RECREATE="$(compose exec -T awg-dash cat /data/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)"
HOST_CFG="$(tr -d '\r\n' < "${HOST_DIR}/config/.ac2-marker" 2>/dev/null || true)"
HOST_DATA="$(tr -d '\r\n' < "${HOST_DIR}/data/.ac2-marker" 2>/dev/null || true)"
if [ "$CFG_AFTER" = "$MARKER_TEXT" ] && [ "$DATA_AFTER" = "$MARKER_TEXT" ] \
&& [ "$CFG_RECREATE" = "$MARKER_TEXT" ] && [ "$DATA_RECREATE" = "$MARKER_TEXT" ]; then
AC2="pass"
ok "AC-2: /config и /data пережили restart и пересоздание контейнера"
if [ "$HOST_CFG" = "$MARKER_TEXT" ] && [ "$HOST_DATA" = "$MARKER_TEXT" ]; then
ok "AC-2: маркеры видны на хосте (${HOST_DIR}/{config,data}) — это bind mount, а не слой контейнера"
else
NOTES+=("маркеры не видны в ${HOST_DIR} — проверьте, что это bind mount, а не volume")
fi
else
AC2="fail"
FAILURES=$((FAILURES + 1))
bad "AC-2: маркеры не пережили restart/пересоздание (cfg=$CFG_AFTER data=$DATA_AFTER recreate=$CFG_RECREATE/$DATA_RECREATE)"
fi
else
AC2="fail"
FAILURES=$((FAILURES + 1))
bad "AC-2: не удалось записать маркеры в /config и /data (exec в контейнер не работает)"
fi
# --- монтирования и здоровье -------------------------------------------------
MOUNTS="$(docker inspect -f '{{range .Mounts}}{{.Destination}}<-{{.Source}}{{"\n"}}{{end}}' "$CID" 2>/dev/null || true)"
printf '%s\n' "$MOUNTS" | grep -q '/config' || { FAILURES=$((FAILURES + 1)); bad "нет монтирования /config"; }
printf '%s\n' "$MOUNTS" | grep -q '/data' || { FAILURES=$((FAILURES + 1)); bad "нет монтирования /data"; }
HEALTH_STATUS="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID" 2>/dev/null || echo unknown)"
say "контейнер: $(docker inspect -f '{{.Name}} {{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID")"
say "монтирования:"; printf '%s\n' "$MOUNTS" | sed 's/^/ /'
say "образ: $(docker inspect -f '{{.Config.Image}}' "$CID")"
if [ "$JSON_OUT" != "" ]; then
mkdir -p "$(dirname "$JSON_OUT")"
cat > "$JSON_OUT" <<EOF
{
"task": "#21",
"host": "$(hostname)",
"container": "$CID",
"image": "$(docker inspect -f '{{.Config.Image}}' "$CID")",
"api_mode": "$API_MODE",
"ac1_health_200": "$AC1",
"ac2_state_persists_restart": "$AC2",
"container_health": "$HEALTH_STATUS",
"host_dir": "${HOST_DIR}",
"host_port": "${PORT}",
"failures": ${FAILURES},
"notes": [$(printf '"%s",' "${NOTES[@]:-}" 2>/dev/null | sed 's/,$//')]
}
EOF
ok "отчёт: ${JSON_OUT}"
fi
if [ "$KEEP_STAND" -eq 0 ]; then
say "стенд оставлен запущенным (снять: docker compose ${COMPOSE_ARGS[*]} down)"
fi
if [ "$FAILURES" -gt 0 ]; then
exit 1
fi
[ "$AC1" = "blocked" ] && exit 2
exit 0