diff --git a/deploy/verify-dev-stand.sh b/deploy/verify-dev-stand.sh new file mode 100644 index 0000000..4c243aa --- /dev/null +++ b/deploy/verify-dev-stand.sh @@ -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://:/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" </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 diff --git a/tests/deploy/validate_compose.py b/tests/deploy/validate_compose.py new file mode 100644 index 0000000..0fa8b3c --- /dev/null +++ b/tests/deploy/validate_compose.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""validate_compose.py — статическая проверка compose-файлов стенда (задача #21). + +Проверяет инварианты, которые нужны acceptance criteria #21 и не проверяются +самим `docker compose config`: + + deploy/docker-compose.dev.yml + * приложение слушает контейнерный порт 8080 и публикуется на хост; + * /config и /data смонтированы (состояние переживает restart); + * healthcheck ходит в /api/v1/health; + * выданы NET_ADMIN-возможности, проброшен /dev/net/tun, включён ip_forward; + * включён ограниченный json-file-лог (иначе логи съедят диск на dev-хосте). + + deploy/docker-compose.test.yml + * сервис netns привилегированный (нужен CAP_SYS_ADMIN/NET_ADMIN для `ip netns add`); + * entrypoint — tests/netns/run.sh, отчёт пишется в /artifacts; + * существуют все файлы, на которые ссылаются тома. + + deploy/docker-compose.stub.yml + * подменяет entrypoint на deploy/api_stub.py и явно помечен как DEV-ONLY. + +Запуск: make test-manifests / python3 tests/deploy/validate_compose.py +Коды возврата: 0 — ок (только warnings), 1 — есть ошибки. +""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +try: + import yaml +except ImportError: # pragma: no cover - зависит от окружения + yaml = None # type: ignore[assignment] + +ROOT = pathlib.Path(__file__).resolve().parents[2] +DEV = ROOT / "deploy" / "docker-compose.dev.yml" +TEST = ROOT / "deploy" / "docker-compose.test.yml" +STUB = ROOT / "deploy" / "docker-compose.stub.yml" + +HEALTH_PATH = "/api/v1/health" + + +def load(path: pathlib.Path) -> dict: + if yaml is None: + raise SystemExit("нужен PyYAML: pip install -r requirements-dev.txt (или make venv)") + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + + +def _ports(service: dict) -> list[str]: + return [str(p) for p in service.get("ports", []) or []] + + +def _volumes(service: dict) -> list[str]: + return [str(v) for v in service.get("volumes", []) or []] + + +def _mount_targets(service: dict) -> list[str]: + """Точки монтирования внутри контейнера. + + Строки вида `${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}/config:/config` содержат + ':' внутри подстановки переменной, поэтому перед разбором подстановки заменяем + на плейсхолдер без двоеточий. + """ + targets = [] + for spec in _volumes(service): + clean = re.sub(r"\$\{[^}]*\}", "SRC", spec) + parts = clean.rsplit(":", 2) + if len(parts) >= 2: + # 2 части: src:target; 3 части: src:target:mode + targets.append(parts[-2] if len(parts) == 3 else parts[-1]) + return targets + + +def validate_dev(errors: list[str], warnings: list[str], require_dockerfile: bool) -> None: + if not DEV.exists(): + errors.append(f"{DEV.name}: файл не найден") + return + doc = load(DEV) + services = doc.get("services") or {} + svc = services.get("awg-dash") + if not svc: + errors.append("dev: нет сервиса awg-dash") + return + + if not doc.get("name"): + errors.append("dev: не задан `name` — compose project не изолирован от других стендов") + + ports = _ports(svc) + if "8080" not in " ".join(ports): + errors.append("dev: порт 8080 не публикуется наружу") + targets = _mount_targets(svc) + for needle in ("/config", "/data"): + if needle not in targets: + errors.append(f"dev: не смонтирован {needle} (состояние не переживёт restart — AC-2)") + + health = (svc.get("healthcheck") or {}).get("test") + if not health or HEALTH_PATH not in jsonish(health): + errors.append(f"dev: healthcheck не ходит в {HEALTH_PATH}") + + caps = [str(c) for c in svc.get("cap_add", []) or []] + if "NET_ADMIN" not in caps: + errors.append("dev: нет cap_add NET_ADMIN (iptables/ipset/ip rule не заработают)") + devices = " ".join(str(d) for d in svc.get("devices", []) or []) + if "/dev/net/tun" not in devices: + errors.append("dev: не проброшен /dev/net/tun") + if str((svc.get("sysctls") or {}).get("net.ipv4.ip_forward", "")) not in ("1", "true", "True"): + errors.append("dev: не включён net.ipv4.ip_forward") + + logging = svc.get("logging") or {} + if str(logging.get("driver", "")) != "json-file": + warnings.append("dev: не задан json-file logging — на dev-хосте логи могут расти без ограничений") + + dockerfile = ((svc.get("build") or {}).get("dockerfile")) or "deploy/Dockerfile" + if not (ROOT / dockerfile).exists(): + msg = f"dev: build.dockerfile {dockerfile} отсутствует в дереве" + (errors if require_dockerfile else warnings).append( + msg + ("" if require_dockerfile else " (ожидаемо до мержа #18)") + ) + + +def validate_test(errors: list[str], warnings: list[str]) -> None: + if not TEST.exists(): + errors.append(f"{TEST.name}: файл не найден") + return + doc = load(TEST) + services = doc.get("services") or {} + svc = services.get("netns") + if not svc: + errors.append("test: нет сервиса netns (netns-харнесс)") + return + if svc.get("privileged") is not True: + errors.append("test: сервис netns не privileged — `ip netns add` внутри контейнера не сработает") + entry = " ".join(str(x) for x in svc.get("entrypoint", []) or []) + if "tests/netns/run.sh" not in entry: + errors.append("test: entrypoint сервиса netns не указывает на tests/netns/run.sh") + if not (ROOT / "tests" / "netns" / "run.sh").exists(): + errors.append("test: нет файла tests/netns/run.sh") + if "/artifacts" not in _mount_targets(svc): + warnings.append("test: отчёт стенда не выносится в /artifacts") + tmpfs = " ".join(str(t) for t in svc.get("tmpfs", []) or []) + if "/tmp" not in tmpfs: + warnings.append("test: /tmp не tmpfs — логи стенда останутся в слое контейнера") + + +def validate_stub(errors: list[str], warnings: list[str]) -> None: + if not STUB.exists(): + warnings.append("stub: оверрайд с health-заглушкой отсутствует") + return + doc = load(STUB) + svc = (doc.get("services") or {}).get("awg-dash") + if not svc: + errors.append("stub: нет сервиса awg-dash") + return + entry = " ".join(str(x) for x in svc.get("entrypoint", []) or []) + if "api_stub.py" not in entry: + errors.append("stub: entrypoint не указывает на deploy/api_stub.py") + if not (ROOT / "deploy" / "api_stub.py").exists(): + errors.append("stub: нет файла deploy/api_stub.py") + if "DEV" not in STUB.read_text(encoding="utf-8").upper(): + warnings.append("stub: в файле нет явной пометки DEV-ONLY") + + +def jsonish(value: object) -> str: + if isinstance(value, list): + return " ".join(str(v) for v in value) + return str(value) + + +def validate(require_dockerfile: bool = False) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + validate_dev(errors, warnings, require_dockerfile) + validate_test(errors, warnings) + validate_stub(errors, warnings) + return errors, warnings + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Проверка compose-файлов dev/тест-стенда awg-dash (#21)") + ap.add_argument("--require-dockerfile", action="store_true", help="требовать наличие deploy/Dockerfile (#18 смёржен)") + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args(argv) + + errors, warnings = validate(args.require_dockerfile) + if not args.quiet: + for warning in warnings: + print(f"[warn] {warning}") + for error in errors: + print(f"[error] {error}") + if errors: + print(f"validate-compose: {len(errors)} ошибок, {len(warnings)} предупреждений") + return 1 + print(f"validate-compose: ok ({len(warnings)} предупреждений)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())