Files
LMS/scripts/ldd-runtime.sh

449 lines
17 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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="${LDD_EVIDENCE_FILE:-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:-$(command -v java || true)}"
[[ -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 os
import statistics
import subprocess
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_auth = expect(
"runtime.course_flow",
"POST",
gateway + "/api/v1/auth/login",
200,
data={"login": "ldd-teacher-" + suffix, "password": "ldd-teacher-123"},
detail="teacher login",
)
teacher_token = teacher_auth["token"]
teacher_refresh = teacher_auth["refreshToken"]
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 доступны преподавателю")
# 3a. Refresh/logout через gateway: без access-токена, с ротацией.
refreshed = expect(
"runtime.auth_refresh",
"POST",
gateway + "/api/v1/auth/refresh",
200,
data={"refreshToken": teacher_refresh},
detail="refresh via gateway",
)
expect(
"runtime.auth_refresh",
"GET",
gateway + "/api/v1/courses/mine",
200,
token=refreshed["token"],
detail="new access works",
)
expect(
"runtime.auth_refresh",
"POST",
gateway + "/api/v1/auth/logout",
204,
data={"refreshToken": refreshed["refreshToken"]},
detail="logout via gateway",
)
expect(
"runtime.auth_refresh",
"POST",
gateway + "/api/v1/auth/refresh",
401,
data={"refreshToken": refreshed["refreshToken"]},
detail="revoked refresh rejected",
)
teacher_token = refreshed["token"]
record("runtime.auth_refresh", "passed", "refresh ротируется, новый access работает, logout отзывает refresh")
# 3b. Масштабный seed для release-нагрузки: 100 групп / 1000 студентов.
if os.environ.get("LDD_SCALE") == "1":
scale_sql = f"""
WITH nums AS (SELECT g FROM generate_series(1, 1000) g),
acc AS (
INSERT INTO identity.accounts (id, login, password_hash, role, must_change_password)
SELECT uuidv7(), 'scale-s-' || g, 'x', 'STUDENT', false FROM nums
RETURNING id, login
)
INSERT INTO identity.students (account_id, student_no, last_name, first_name)
SELECT id, 'SCALE-' || lpad(split_part(login, '-', 3), 4, '0'), 'Студентов', 'Студент' FROM acc;
INSERT INTO identity.groups (id, name, stream, course, number, degree, enrollment_year, status)
SELECT uuidv7(), 'SCALE-' || g || 'БО', 'SCALE', 1, g, 'БО', 2026, 'ACTIVE'
FROM generate_series(1, 100) g;
INSERT INTO academic.course_groups (id, course_id, group_id, added_at)
SELECT uuidv7(), '{course["id"]}', id, now() FROM identity.groups WHERE stream = 'SCALE';
WITH g AS (
SELECT id, row_number() OVER (ORDER BY name) rn FROM identity.groups WHERE stream = 'SCALE'
), s AS (
SELECT account_id, row_number() OVER (ORDER BY student_no) rn FROM identity.students WHERE student_no LIKE 'SCALE-%'
)
INSERT INTO identity.enrollments (id, group_id, student_id)
SELECT uuidv7(), g.id, s.account_id FROM g JOIN s ON ((s.rn - 1) / 10) + 1 = g.rn;
"""
subprocess.run(
["docker", "exec", "-i", "lms-postgres", "psql", "-U", "lms", "-d", os.environ["LMS_DB_NAME"], "-v", "ON_ERROR_STOP=1"],
input=scale_sql,
text=True,
check=True,
stdout=subprocess.DEVNULL,
)
record("runtime.scale_seed", "passed", "100 групп / 1000 студентов")
# 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 чтения: курс, лабы, профиль и аудит.
smoke_only = os.environ.get("LDD_SMOKE") == "1"
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/courses/" + course["id"] + "/groups", 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
if not smoke_only:
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