ci: дымовой тест на push и полный release gate
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
# должен разрешать privileged-сервисы (docker:dind) и сеть до docker:2375.
|
# должен разрешать privileged-сервисы (docker:dind) и сеть до docker:2375.
|
||||||
stages:
|
stages:
|
||||||
- check
|
- check
|
||||||
|
- smoke
|
||||||
- release-evidence
|
- release-evidence
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
@@ -33,18 +34,36 @@ check:
|
|||||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||||
- if: '$CI_COMMIT_BRANCH'
|
- if: '$CI_COMMIT_BRANCH'
|
||||||
|
|
||||||
|
# Дымовой тест на каждый push/MR: полный стек на одноразовой БД, health,
|
||||||
|
# вход и один доменный сценарий. Полный release gate — ниже.
|
||||||
|
smoke:
|
||||||
|
stage: smoke
|
||||||
|
# Shell-runner: скрипт поднимает реальный стек на localhost и проверяет его.
|
||||||
|
# На runner нужны Java 21, Docker Compose v2, Python 3 и bash.
|
||||||
|
tags: [lms-shell]
|
||||||
|
cache:
|
||||||
|
key: "gradle-$CI_COMMIT_REF_SLUG"
|
||||||
|
paths:
|
||||||
|
- .gradle/caches
|
||||||
|
- .gradle/wrapper
|
||||||
|
script:
|
||||||
|
- ./scripts/ldd-smoke.sh
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
paths:
|
||||||
|
- build/evidence/smoke.json
|
||||||
|
rules:
|
||||||
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||||
|
- if: '$CI_COMMIT_BRANCH'
|
||||||
|
|
||||||
# Ручной release gate: runtime, mutation и browser evidence.
|
# Ручной release gate: runtime, mutation и browser evidence.
|
||||||
# Нужен privileged docker:dind; Chromium и Node ставятся в before_script.
|
# Нужен privileged docker:dind; Chromium и Node ставятся в before_script.
|
||||||
release-evidence:
|
release-evidence:
|
||||||
stage: release-evidence
|
stage: release-evidence
|
||||||
image: eclipse-temurin:21-jdk
|
# Shell-runner с Java 21, Docker Compose v2, Node 22, Chromium Playwright.
|
||||||
services:
|
tags: [lms-release]
|
||||||
- name: docker:27-dind
|
|
||||||
alias: docker
|
|
||||||
before_script:
|
before_script:
|
||||||
- apt-get update
|
- cd e2e && npm ci && npx playwright install chromium && cd ..
|
||||||
- apt-get install -y nodejs npm
|
|
||||||
- cd e2e && npm ci && npx playwright install --with-deps chromium && cd ..
|
|
||||||
cache:
|
cache:
|
||||||
key: "gradle-$CI_COMMIT_REF_SLUG"
|
key: "gradle-$CI_COMMIT_REF_SLUG"
|
||||||
paths:
|
paths:
|
||||||
@@ -58,5 +77,7 @@ release-evidence:
|
|||||||
- build/evidence/
|
- build/evidence/
|
||||||
- build/reports/evidence/
|
- build/reports/evidence/
|
||||||
rules:
|
rules:
|
||||||
|
- if: '$CI_COMMIT_TAG'
|
||||||
|
when: on_success
|
||||||
- if: '$CI_COMMIT_BRANCH'
|
- if: '$CI_COMMIT_BRANCH'
|
||||||
when: manual
|
when: manual
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ done
|
|||||||
|
|
||||||
command -v docker >/dev/null 2>&1 || { echo "Нужен docker" >&2; exit 1; }
|
command -v docker >/dev/null 2>&1 || { echo "Нужен docker" >&2; exit 1; }
|
||||||
command -v npx >/dev/null 2>&1 || { echo "Нужен Node.js/npx" >&2; exit 1; }
|
command -v npx >/dev/null 2>&1 || { echo "Нужен Node.js/npx" >&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; }
|
[[ -x "$JAVA_BIN" ]] || { echo "Не найден Java 21: $JAVA_BIN" >&2; exit 1; }
|
||||||
[[ -d e2e/node_modules ]] || { echo "Выполните: cd e2e && npm install" >&2; exit 1; }
|
[[ -d e2e/node_modules ]] || { echo "Выполните: cd e2e && npm install" >&2; exit 1; }
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ log "Применяю миграции ветки..."
|
|||||||
cat services/identity-service/src/main/resources/db/migration/V8__membership_history.sql
|
cat services/identity-service/src/main/resources/db/migration/V8__membership_history.sql
|
||||||
cat services/identity-service/src/main/resources/db/migration/V9__account_statuses.sql
|
cat services/identity-service/src/main/resources/db/migration/V9__account_statuses.sql
|
||||||
cat services/identity-service/src/main/resources/db/migration/V10__audit_events.sql
|
cat services/identity-service/src/main/resources/db/migration/V10__audit_events.sql
|
||||||
|
cat services/identity-service/src/main/resources/db/migration/V11__refresh_tokens.sql
|
||||||
echo 'SET search_path TO academic;'
|
echo 'SET search_path TO academic;'
|
||||||
cat services/academic-service/src/main/resources/db/migration/V6__courses.sql
|
cat services/academic-service/src/main/resources/db/migration/V6__courses.sql
|
||||||
cat services/academic-service/src/main/resources/db/migration/V7__labs_course.sql
|
cat services/academic-service/src/main/resources/db/migration/V7__labs_course.sql
|
||||||
@@ -119,6 +120,8 @@ CHECK_SQL=$(cat <<'SQL'
|
|||||||
SET search_path TO identity;
|
SET search_path TO identity;
|
||||||
SELECT 'audit_events=' || count(*) FROM information_schema.tables
|
SELECT 'audit_events=' || count(*) FROM information_schema.tables
|
||||||
WHERE table_schema='identity' AND table_name='audit_events';
|
WHERE table_schema='identity' AND table_name='audit_events';
|
||||||
|
SELECT 'refresh_tokens=' || count(*) FROM information_schema.tables
|
||||||
|
WHERE table_schema='identity' AND table_name='refresh_tokens';
|
||||||
SELECT 'students=' || count(*) FROM students;
|
SELECT 'students=' || count(*) FROM students;
|
||||||
SELECT 'groups=' || count(*) FROM groups;
|
SELECT 'groups=' || count(*) FROM groups;
|
||||||
SELECT 'history=' || count(*) FROM membership_history;
|
SELECT 'history=' || count(*) FROM membership_history;
|
||||||
@@ -159,6 +162,7 @@ import sys
|
|||||||
out, result, v13 = sys.argv[1], sys.argv[2], sys.argv[3]
|
out, result, v13 = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
required = {
|
required = {
|
||||||
"audit_events=1",
|
"audit_events=1",
|
||||||
|
"refresh_tokens=1",
|
||||||
"students=1",
|
"students=1",
|
||||||
"groups=1",
|
"groups=1",
|
||||||
"history=1",
|
"history=1",
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ log "2/6 Upgrade с master-схемы"
|
|||||||
log "3/6 Mutation testing"
|
log "3/6 Mutation testing"
|
||||||
./scripts/ldd-mutation.sh
|
./scripts/ldd-mutation.sh
|
||||||
|
|
||||||
log "4/6 Runtime smoke"
|
log "4/6 Runtime smoke + scale load"
|
||||||
./scripts/ldd-runtime.sh
|
LDD_SCALE=1 ./scripts/ldd-runtime.sh
|
||||||
|
|
||||||
log "5/6 Browser E2E"
|
log "5/6 Browser E2E"
|
||||||
./scripts/ldd-browser.sh
|
./scripts/ldd-browser.sh
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
|||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
DB="lms_ldd_$(date -u +%H%M%S)_$$"
|
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
|
POSTGRES_WAS_RUNNING=0
|
||||||
STACK_STARTED=0
|
STACK_STARTED=0
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ done
|
|||||||
|
|
||||||
command -v docker >/dev/null 2>&1 || { echo "Нужен docker" >&2; exit 1; }
|
command -v docker >/dev/null 2>&1 || { echo "Нужен docker" >&2; exit 1; }
|
||||||
command -v python3 >/dev/null 2>&1 || { echo "Нужен python3" >&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; }
|
[[ -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
|
if docker compose ps --status running --services 2>/dev/null | grep -qx postgres; then
|
||||||
@@ -80,7 +80,9 @@ log "Проверяю HTTP-сценарии..."
|
|||||||
python3 - "$EVIDENCE" <<'PY'
|
python3 - "$EVIDENCE" <<'PY'
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import statistics
|
import statistics
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -235,14 +237,16 @@ try:
|
|||||||
token=admin_token,
|
token=admin_token,
|
||||||
detail="create student",
|
detail="create student",
|
||||||
)
|
)
|
||||||
teacher_token = expect(
|
teacher_auth = expect(
|
||||||
"runtime.course_flow",
|
"runtime.course_flow",
|
||||||
"POST",
|
"POST",
|
||||||
gateway + "/api/v1/auth/login",
|
gateway + "/api/v1/auth/login",
|
||||||
200,
|
200,
|
||||||
data={"login": "ldd-teacher-" + suffix, "password": "ldd-teacher-123"},
|
data={"login": "ldd-teacher-" + suffix, "password": "ldd-teacher-123"},
|
||||||
detail="teacher login",
|
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)
|
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):
|
if not any(item.get("id") == course["id"] for item in mine):
|
||||||
record("runtime.course_flow", "failed", "курс не найден в /courses/mine преподавателя")
|
record("runtime.course_flow", "failed", "курс не найден в /courses/mine преподавателя")
|
||||||
@@ -269,6 +273,78 @@ try:
|
|||||||
raise AssertionError("roster")
|
raise AssertionError("roster")
|
||||||
record("runtime.course_flow", "passed", "группа/курс/преподаватель/лаба/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. Чужой преподаватель не получает доступ к курсу/лабе/аудиту.
|
# 4. Чужой преподаватель не получает доступ к курсу/лабе/аудиту.
|
||||||
outsider = expect(
|
outsider = expect(
|
||||||
"runtime.foreign_teacher_denied",
|
"runtime.foreign_teacher_denied",
|
||||||
@@ -303,10 +379,12 @@ try:
|
|||||||
record("runtime.audit_visible", "passed", f"аудит содержит {sorted(required)}")
|
record("runtime.audit_visible", "passed", f"аудит содержит {sorted(required)}")
|
||||||
|
|
||||||
# 5b. Нагрузочный smoke чтения: курс, лабы, профиль и аудит.
|
# 5b. Нагрузочный smoke чтения: курс, лабы, профиль и аудит.
|
||||||
|
smoke_only = os.environ.get("LDD_SMOKE") == "1"
|
||||||
load_paths = [
|
load_paths = [
|
||||||
(gateway + "/api/v1/courses/" + course["id"], teacher_token),
|
(gateway + "/api/v1/courses/" + course["id"], teacher_token),
|
||||||
(gateway + "/api/v1/labs?courseId=" + 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"] + "/students/" + student["id"] + "/profile", teacher_token),
|
||||||
|
(gateway + "/api/v1/courses/" + course["id"] + "/groups", teacher_token),
|
||||||
(gateway + "/api/v1/audit?limit=100", admin_token),
|
(gateway + "/api/v1/audit?limit=100", admin_token),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -315,30 +393,31 @@ try:
|
|||||||
status, _ = req("GET", path, token=token)
|
status, _ = req("GET", path, token=token)
|
||||||
return status, time.perf_counter() - started
|
return status, time.perf_counter() - started
|
||||||
|
|
||||||
latencies = []
|
if not smoke_only:
|
||||||
errors = 0
|
latencies = []
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
|
errors = 0
|
||||||
futures = [
|
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
|
||||||
pool.submit(timed_get, path, token)
|
futures = [
|
||||||
for _ in range(25)
|
pool.submit(timed_get, path, token)
|
||||||
for path, token in load_paths
|
for _ in range(25)
|
||||||
]
|
for path, token in load_paths
|
||||||
for future in concurrent.futures.as_completed(futures):
|
]
|
||||||
status, latency = future.result()
|
for future in concurrent.futures.as_completed(futures):
|
||||||
latencies.append(latency)
|
status, latency = future.result()
|
||||||
if status != 200:
|
latencies.append(latency)
|
||||||
errors += 1
|
if status != 200:
|
||||||
latencies.sort()
|
errors += 1
|
||||||
p95 = latencies[min(len(latencies) - 1, int(len(latencies) * 0.95))]
|
latencies.sort()
|
||||||
average = statistics.mean(latencies)
|
p95 = latencies[min(len(latencies) - 1, int(len(latencies) * 0.95))]
|
||||||
load_ok = errors == 0 and p95 < 2.0
|
average = statistics.mean(latencies)
|
||||||
record(
|
load_ok = errors == 0 and p95 < 2.0
|
||||||
"runtime.load_gradebook_profile",
|
record(
|
||||||
"passed" if load_ok else "failed",
|
"runtime.load_gradebook_profile",
|
||||||
f"requests={len(latencies)}, errors={errors}, avg={average:.3f}s, p95={p95:.3f}s",
|
"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 load_ok:
|
||||||
|
raise AssertionError("load smoke")
|
||||||
|
|
||||||
# 6. Web-страница входа и публичное дерево.
|
# 6. Web-страница входа и публичное дерево.
|
||||||
status, _ = req("GET", web + "/login")
|
status, _ = req("GET", web + "/login")
|
||||||
|
|||||||
6
scripts/ldd-smoke.sh
Executable file
6
scripts/ldd-smoke.sh
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Быстрый дымовой тест для каждого коммита: стек + health + вход + один курс.
|
||||||
|
# Полный release gate: scripts/ldd-release.sh.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
LDD_SMOKE=1 LDD_EVIDENCE_FILE=build/evidence/smoke.json exec ./scripts/ldd-runtime.sh
|
||||||
Reference in New Issue
Block a user