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:
@@ -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
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user