ci: дымовой тест на push и полный release gate

This commit is contained in:
2026-10-28 13:26:38 +03:00
parent 4ef6c7f7ed
commit 71fa3284a0
6 changed files with 148 additions and 38 deletions

View File

@@ -11,7 +11,7 @@ set -euo pipefail
cd "$(dirname "$0")/.."
DB="lms_ldd_$(date -u +%H%M%S)_$$"
EVIDENCE="build/evidence/runtime.json"
EVIDENCE="${LDD_EVIDENCE_FILE:-build/evidence/runtime.json}"
POSTGRES_WAS_RUNNING=0
STACK_STARTED=0
@@ -41,7 +41,7 @@ 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}"
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
@@ -80,7 +80,9 @@ log "Проверяю HTTP-сценарии..."
python3 - "$EVIDENCE" <<'PY'
import concurrent.futures
import json
import os
import statistics
import subprocess
import sys
import time
import urllib.error
@@ -235,14 +237,16 @@ try:
token=admin_token,
detail="create student",
)
teacher_token = expect(
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",
)["token"]
)
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 преподавателя")
@@ -269,6 +273,78 @@ try:
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",
@@ -303,10 +379,12 @@ try:
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),
]
@@ -315,30 +393,31 @@ try:
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")
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")