#!/usr/bin/env bash # LDD runtime smoke: поднимает полный стек на одноразовой БД и проверяет # ключевые сценарии через реальные HTTP-вызовы. Не трогает рабочую БД lms. # # ./scripts/ldd-runtime.sh # # Требования: docker compose v2, Java 21, curl, python3. Скрипт отказывается # работать, если порты 8080/8081/8082/8083/8090 уже заняты. set -euo pipefail cd "$(dirname "$0")/.." DB="lms_ldd_$(date -u +%H%M%S)_$$" EVIDENCE="build/evidence/runtime.json" POSTGRES_WAS_RUNNING=0 STACK_STARTED=0 log() { echo ">> $*"; } cleanup() { local code=$? if ((STACK_STARTED)); then log "Останавливаю тестовый стек..." ./run.sh stop >/dev/null 2>&1 || true fi docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \ -c "DROP DATABASE IF EXISTS $DB;" >/dev/null 2>&1 || true if ((POSTGRES_WAS_RUNNING == 0)); then docker compose stop postgres >/dev/null 2>&1 || true fi exit $code } trap cleanup EXIT INT TERM for port in 8080 8081 8082 8083 8090; do if ss -ltn 2>/dev/null | grep -q ":$port "; then echo "Порт $port занят. Остановите текущий стек (./run.sh stop) и повторите." >&2 exit 1 fi done command -v docker >/dev/null 2>&1 || { echo "Нужен docker" >&2; exit 1; } command -v python3 >/dev/null 2>&1 || { echo "Нужен python3" >&2; exit 1; } JAVA_BIN="${JAVA_BIN:-/usr/lib/jvm/java-21-openjdk/bin/java}" [[ -x "$JAVA_BIN" ]] || { echo "Не найден Java 21: $JAVA_BIN" >&2; exit 1; } if docker compose ps --status running --services 2>/dev/null | grep -qx postgres; then POSTGRES_WAS_RUNNING=1 fi log "Запускаю PostgreSQL..." docker compose up -d postgres >/dev/null for _ in $(seq 1 40); do docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null 2>&1 && break sleep 1 done docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null log "Создаю одноразовую БД $DB..." docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \ -c "CREATE DATABASE $DB;" >/dev/null log "Собираю bootJar..." ./gradlew bootJar --console=plain >/tmp/lms-ldd-bootjar.log log "Запускаю стек..." export LMS_DB_URL="jdbc:postgresql://localhost:5432/$DB" export LMS_DB_NAME="$DB" export LMS_DB_USER="lms" export LMS_DB_PASSWORD="lms" export LMS_INTERNAL_TOKEN="ldd-internal-token" export LMS_SESSION_COOKIE_SECURE="false" export JAVA_BIN STACK_STARTED=1 ./run.sh start mkdir -p "$(dirname "$EVIDENCE")" log "Проверяю HTTP-сценарии..." python3 - "$EVIDENCE" <<'PY' import concurrent.futures import json import statistics import sys import time import urllib.error import urllib.request import uuid out_path = sys.argv[1] gateway = "http://localhost:8080" web = "http://localhost:8090" checks = [] failures = [] def record(check_id, status, detail): checks.append({"id": check_id, "status": status, "detail": detail}) print(("PASS " if status == "passed" else "FAIL ") + check_id + ": " + detail) if status != "passed": failures.append(check_id) def req(method, url, data=None, token=None): headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = "Bearer " + token body = json.dumps(data).encode() if data is not None else None request = urllib.request.Request(url, data=body, headers=headers, method=method) try: with urllib.request.urlopen(request, timeout=30) as response: raw = response.read().decode() return response.status, json.loads(raw) if raw.strip().startswith(("{", "[")) else raw except urllib.error.HTTPError as exc: raw = exc.read().decode() try: parsed = json.loads(raw) except Exception: parsed = raw return exc.code, parsed def expect(check_id, method, url, expected, data=None, token=None, detail=""): status, body = req(method, url, data=data, token=token) if status != expected: record(check_id, "failed", f"{detail or url}: ожидался {expected}, получен {status}: {body}") raise AssertionError(check_id) record(check_id, "passed", detail or f"{method} {url} -> {status}") return body try: # 1. Health всех сервисов. for name, port in [("identity", 8081), ("academic", 8082), ("progress", 8083), ("gateway", 8080), ("web", 8090)]: status, body = req("GET", f"http://localhost:{port}/actuator/health") if status != 200 or not isinstance(body, dict) or body.get("status") != "UP": record("runtime.stack_health", "failed", f"{name}: {status} {body}") raise AssertionError("stack_health") record("runtime.stack_health", "passed", "identity/academic/progress/gateway/web health=UP") # 2. Admin setup + login через gateway. health = expect("runtime.admin_setup_login", "GET", gateway + "/api/v1/auth/health", 200) if health.get("adminExists") is not False: record("runtime.admin_setup_login", "failed", f"adminExists до setup: {health}") raise AssertionError("adminExists") admin = expect( "runtime.admin_setup_login", "POST", gateway + "/api/v1/auth/setup", 200, data={"login": "ldd-admin", "password": "ldd-password-123", "password2": "ldd-password-123"}, detail="setup admin", ) admin_token = admin["token"] me = expect("runtime.admin_setup_login", "GET", gateway + "/api/v1/auth/me", 200, token=admin_token) if me.get("role") != "ADMIN" or not me.get("active", False): record("runtime.admin_setup_login", "failed", f"/auth/me: {me}") raise AssertionError("me") # 3. Реальный доменный поток: группа -> курс -> преподаватель -> группа курса -> лаба. suffix = uuid.uuid4().hex[:6] group = expect( "runtime.course_flow", "POST", gateway + "/api/v1/groups", 200, data={"stream": "LDD" + suffix, "course": 1, "number": 1, "degree": "БО", "enrollmentYear": 2026}, token=admin_token, detail="create group", ) course = expect( "runtime.course_flow", "POST", gateway + "/api/v1/courses", 200, data={"title": "LDD runtime course " + suffix}, token=admin_token, detail="create course", ) teacher = expect( "runtime.course_flow", "POST", gateway + "/api/v1/teachers", 200, data={"login": "ldd-teacher-" + suffix, "password": "ldd-teacher-123"}, token=admin_token, detail="create teacher", ) expect( "runtime.course_flow", "POST", gateway + "/api/v1/courses/" + course["id"] + "/teachers", 200, data={"accountId": teacher["id"]}, token=admin_token, detail="assign teacher", ) expect( "runtime.course_flow", "POST", gateway + "/api/v1/courses/" + course["id"] + "/groups", 200, data={"groupId": group["id"]}, token=admin_token, detail="attach group", ) lab = expect( "runtime.course_flow", "POST", gateway + "/api/v1/labs", 200, data={ "number": 1, "title": "LDD lab", "status": "published", "courseId": course["id"], "levels": [{"code": "basic", "title": "Базовый", "position": 1}], }, token=admin_token, detail="create lab", ) student = expect( "runtime.course_flow", "POST", gateway + "/api/v1/groups/" + group["id"] + "/students", 200, data={ "studentNo": "ldd-student-" + suffix, "lastName": "Тестов", "firstName": "Студент", "middleName": "", "basis": "бюджет", "note": "", }, token=admin_token, detail="create student", ) teacher_token = expect( "runtime.course_flow", "POST", gateway + "/api/v1/auth/login", 200, data={"login": "ldd-teacher-" + suffix, "password": "ldd-teacher-123"}, detail="teacher login", )["token"] mine = expect("runtime.course_flow", "GET", gateway + "/api/v1/courses/mine", 200, token=teacher_token) if not any(item.get("id") == course["id"] for item in mine): record("runtime.course_flow", "failed", "курс не найден в /courses/mine преподавателя") raise AssertionError("mine") labs = expect( "runtime.course_flow", "GET", gateway + "/api/v1/labs?courseId=" + course["id"], 200, token=teacher_token, ) if not any(item.get("id") == lab["id"] for item in labs): record("runtime.course_flow", "failed", "лаба не найдена у преподавателя") raise AssertionError("lab list") roster = expect( "runtime.course_flow", "GET", gateway + "/api/v1/courses/" + course["id"] + "/groups/" + group["id"] + "/students", 200, token=teacher_token, ) if roster.get("groupId") != group["id"]: record("runtime.course_flow", "failed", f"roster: {roster}") raise AssertionError("roster") record("runtime.course_flow", "passed", "группа/курс/преподаватель/лаба/roster доступны преподавателю") # 4. Чужой преподаватель не получает доступ к курсу/лабе/аудиту. outsider = expect( "runtime.foreign_teacher_denied", "POST", gateway + "/api/v1/teachers", 200, data={"login": "ldd-outsider-" + suffix, "password": "ldd-outsider-123"}, token=admin_token, detail="create outsider", ) outsider_token = expect( "runtime.foreign_teacher_denied", "POST", gateway + "/api/v1/auth/login", 200, data={"login": "ldd-outsider-" + suffix, "password": "ldd-outsider-123"}, detail="outsider login", )["token"] expect("runtime.foreign_teacher_denied", "GET", gateway + "/api/v1/courses/" + course["id"], 403, token=outsider_token, detail="foreign course 403") expect("runtime.foreign_teacher_denied", "GET", gateway + "/api/v1/labs/" + lab["id"], 403, token=outsider_token, detail="foreign lab 403") expect("runtime.foreign_teacher_denied", "GET", gateway + "/api/v1/audit", 403, token=outsider_token, detail="audit 403") record("runtime.foreign_teacher_denied", "passed", "чужой преподаватель получает 403 на курс/лабу/аудит") # 5. Аудит доступен админу и содержит ключевые события. events = expect("runtime.audit_visible", "GET", gateway + "/api/v1/audit?limit=200", 200, token=admin_token) actions = {event.get("action") for event in events} required = {"COURSE_CREATE", "TEACHER_CREATE", "COURSE_TEACHER_ASSIGN", "COURSE_GROUP_ATTACH", "LAB_CREATE"} absent = required - actions if absent: record("runtime.audit_visible", "failed", f"в аудите нет: {sorted(absent)}; есть: {sorted(actions)}") raise AssertionError("audit actions") record("runtime.audit_visible", "passed", f"аудит содержит {sorted(required)}") # 5b. Нагрузочный smoke чтения: курс, лабы, профиль и аудит. load_paths = [ (gateway + "/api/v1/courses/" + course["id"], teacher_token), (gateway + "/api/v1/labs?courseId=" + course["id"], teacher_token), (gateway + "/api/v1/courses/" + course["id"] + "/students/" + student["id"] + "/profile", teacher_token), (gateway + "/api/v1/audit?limit=100", admin_token), ] def timed_get(path, token): started = time.perf_counter() status, _ = req("GET", path, token=token) return status, time.perf_counter() - started latencies = [] errors = 0 with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool: futures = [ pool.submit(timed_get, path, token) for _ in range(25) for path, token in load_paths ] for future in concurrent.futures.as_completed(futures): status, latency = future.result() latencies.append(latency) if status != 200: errors += 1 latencies.sort() p95 = latencies[min(len(latencies) - 1, int(len(latencies) * 0.95))] average = statistics.mean(latencies) load_ok = errors == 0 and p95 < 2.0 record( "runtime.load_gradebook_profile", "passed" if load_ok else "failed", f"requests={len(latencies)}, errors={errors}, avg={average:.3f}s, p95={p95:.3f}s", ) if not load_ok: raise AssertionError("load smoke") # 6. Web-страница входа и публичное дерево. status, _ = req("GET", web + "/login") if status != 200: record("runtime.web_login_page", "failed", f"/login -> {status}") raise AssertionError("web login") record("runtime.web_login_page", "passed", "web /login -> 200") expect("runtime.web_login_page", "GET", gateway + "/api/v1/content/tree", 200, detail="public content tree") expect("runtime.web_login_page", "GET", gateway + "/api/v1/courses/" + course["id"] + "/content/tree", 401, detail="anonymous course content 401") except AssertionError: pass except Exception as exc: record("runtime.unexpected", "failed", f"{type(exc).__name__}: {exc}") with open(out_path, "w", encoding="utf-8") as fh: now = __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat() json.dump({"generatedAt": now, "checks": checks}, fh, ensure_ascii=False, indent=2) if failures: print("Неуспешные runtime-проверки: " + ", ".join(failures), file=sys.stderr) sys.exit(1) print("Все runtime-проверки пройдены.") PY if [[ -f "$EVIDENCE" ]]; then log "Runtime evidence: $EVIDENCE" fi