From 71fa3284a0cb4b0fbba48888343c24fda4502325 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Wed, 28 Oct 2026 13:26:38 +0300 Subject: [PATCH] =?UTF-8?q?ci:=20=D0=B4=D1=8B=D0=BC=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=82=D0=B5=D1=81=D1=82=20=D0=BD=D0=B0=20push=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20release=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 35 ++++++-- scripts/ldd-browser.sh | 2 +- scripts/ldd-migration-upgrade.sh | 4 + scripts/ldd-release.sh | 4 +- scripts/ldd-runtime.sh | 135 ++++++++++++++++++++++++------- scripts/ldd-smoke.sh | 6 ++ 6 files changed, 148 insertions(+), 38 deletions(-) create mode 100755 scripts/ldd-smoke.sh diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0c2413b..80e907f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,6 +3,7 @@ # должен разрешать privileged-сервисы (docker:dind) и сеть до docker:2375. stages: - check + - smoke - release-evidence variables: @@ -33,18 +34,36 @@ check: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - 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. # Нужен privileged docker:dind; Chromium и Node ставятся в before_script. release-evidence: stage: release-evidence - image: eclipse-temurin:21-jdk - services: - - name: docker:27-dind - alias: docker + # Shell-runner с Java 21, Docker Compose v2, Node 22, Chromium Playwright. + tags: [lms-release] before_script: - - apt-get update - - apt-get install -y nodejs npm - - cd e2e && npm ci && npx playwright install --with-deps chromium && cd .. + - cd e2e && npm ci && npx playwright install chromium && cd .. cache: key: "gradle-$CI_COMMIT_REF_SLUG" paths: @@ -58,5 +77,7 @@ release-evidence: - build/evidence/ - build/reports/evidence/ rules: + - if: '$CI_COMMIT_TAG' + when: on_success - if: '$CI_COMMIT_BRANCH' when: manual diff --git a/scripts/ldd-browser.sh b/scripts/ldd-browser.sh index 4287832..29ee333 100755 --- a/scripts/ldd-browser.sh +++ b/scripts/ldd-browser.sh @@ -35,7 +35,7 @@ done 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; } -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; } [[ -d e2e/node_modules ]] || { echo "Выполните: cd e2e && npm install" >&2; exit 1; } diff --git a/scripts/ldd-migration-upgrade.sh b/scripts/ldd-migration-upgrade.sh index 30105db..c37d12f 100755 --- a/scripts/ldd-migration-upgrade.sh +++ b/scripts/ldd-migration-upgrade.sh @@ -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/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/V11__refresh_tokens.sql 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/V7__labs_course.sql @@ -119,6 +120,8 @@ CHECK_SQL=$(cat <<'SQL' SET search_path TO identity; SELECT 'audit_events=' || count(*) FROM information_schema.tables 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 'groups=' || count(*) FROM groups; SELECT 'history=' || count(*) FROM membership_history; @@ -159,6 +162,7 @@ import sys out, result, v13 = sys.argv[1], sys.argv[2], sys.argv[3] required = { "audit_events=1", + "refresh_tokens=1", "students=1", "groups=1", "history=1", diff --git a/scripts/ldd-release.sh b/scripts/ldd-release.sh index 27e878b..3682bc4 100755 --- a/scripts/ldd-release.sh +++ b/scripts/ldd-release.sh @@ -16,8 +16,8 @@ log "2/6 Upgrade с master-схемы" log "3/6 Mutation testing" ./scripts/ldd-mutation.sh -log "4/6 Runtime smoke" -./scripts/ldd-runtime.sh +log "4/6 Runtime smoke + scale load" +LDD_SCALE=1 ./scripts/ldd-runtime.sh log "5/6 Browser E2E" ./scripts/ldd-browser.sh diff --git a/scripts/ldd-runtime.sh b/scripts/ldd-runtime.sh index 924d416..f8b8204 100755 --- a/scripts/ldd-runtime.sh +++ b/scripts/ldd-runtime.sh @@ -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") diff --git a/scripts/ldd-smoke.sh b/scripts/ldd-smoke.sh new file mode 100755 index 0000000..516c5b5 --- /dev/null +++ b/scripts/ldd-smoke.sh @@ -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