#!/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://:/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; } CID="$(cid)" [ -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 # контейнер пересоздан — id сменился; без перезапроса docker inspect падает с 'no such object' CID="$(cid)" [ -n "$CID" ] || fatal_env "контейнер awg-dash не найден после пересоздания" 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 # --- монтирования и здоровье ------------------------------------------------- CID="$(cid)" [ -n "$CID" ] || fatal_env "контейнер awg-dash не найден в compose project (после перезапуска)" 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" </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