From eb48c83c7e602b5bdc12b9102f24b669838f03e0 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 23 Oct 2026 17:53:51 +0700 Subject: [PATCH] =?UTF-8?q?fix(ci):=20smoke=20=D0=B8=20nightly=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D1=89=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D0=BA?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D1=83?= =?UTF-8?q?=20=D0=B8=D0=B7=D0=BD=D1=83=D1=82=D1=80=D0=B8=20(=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D0=B5=D1=80=20=D1=81=D0=B0=D0=BC=20=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/nightly-perf.yaml | 24 +++++++------- scripts/ci/smoke-image.sh | 50 ++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/.gitea/workflows/nightly-perf.yaml b/.gitea/workflows/nightly-perf.yaml index f1335b7..481a86b 100644 --- a/.gitea/workflows/nightly-perf.yaml +++ b/.gitea/workflows/nightly-perf.yaml @@ -30,20 +30,20 @@ jobs: run: | set -euxo pipefail docker volume create load-data - docker run --rm -v load-data:/data --entrypoint sh authportal:load -c 'chown 1000:1000 /data' - docker run -d --name authportal-load --init --memory 256m --memory-swap 256m --cpus 1.0 \ - --read-only --tmpfs /tmp --cap-drop ALL -v load-data:/data -p 18080:8080 \ - -e JWT_SECRET=load-jwt-secret-0123456789 -e ADMIN_PASSWORD=load-admin-pass-1 \ - -e ADMIN_EMAIL=admin@smoke.local -e ISSUER=http://127.0.0.1:18080 -e RATE_LIMIT_MAX=1000000 authportal:load - for i in $(seq 1 40); do curl -fsS http://127.0.0.1:18080/ready && break; sleep 0.5; done - - name: Load test - env: - PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }} - LOAD_PASSWORD: load-admin-pass-1 + docker run --rm -v load-data:/data --user root --entrypoint sh authportal:load -c 'chown 1000:1000 /data' + docker run -d --name authportal-load --init --memory 256m --memory-swap 256m --cpus 1.0 --read-only --tmpfs /tmp --cap-drop ALL -v load-data:/data -e JWT_SECRET=load-jwt-secret-0123456789 -e ADMIN_PASSWORD=load-admin-pass-1 -e ADMIN_EMAIL=admin@smoke.local -e ISSUER=http://127.0.0.1:8080 -e RATE_LIMIT_MAX=1000000 authportal:load + for i in $(seq 1 40); do docker exec authportal-load wget -q -O /dev/null http://127.0.0.1:8080/ready && break; sleep 0.5; done + # Раннер сам в контейнере: нагрузку гоним из соседнего контейнера в сетевом namespace приложения. + - name: Load test (autocannon в namespace приложения) run: | set -euxo pipefail - sh scripts/ci/pnpm-ci.sh - node scripts/perf/load.mjs http://127.0.0.1:18080 authportal-load + docker run --rm -i --network container:authportal-load -e LOAD_PASSWORD=load-admin-pass-1 -e LOAD_DURATION_S=15 node:22-alpine sh -c 'mkdir /w && cd /w && npm i --silent --no-audit --no-fund autocannon@7.15.0 && cat > load.mjs && node load.mjs http://127.0.0.1:8080' < scripts/perf/load.mjs + - name: RSS после нагрузки + run: | + set -euxo pipefail + rss=$(docker stats --no-stream --format '{{.MemUsage}}' authportal-load | sed -E 's/^([0-9.]+)([KMG]i?B).*/ /' | awk '{u=$2; v=$1; if(u ~ /^G/) v*=1024; else if(u ~ /^K/) v/=1024; printf "%d", v}') + echo "RSS: ${rss} MiB (budget 200)" + [ "$rss" -le 200 ] - name: Container logs on failure if: failure() run: docker logs --tail 100 authportal-load || true diff --git a/scripts/ci/smoke-image.sh b/scripts/ci/smoke-image.sh index 53fdbac..41f635f 100755 --- a/scripts/ci/smoke-image.sh +++ b/scripts/ci/smoke-image.sh @@ -2,13 +2,15 @@ # Smoke-тест собранного образа: запуск с боевыми ограничениями (как в compose), # /ready, логин, JWKS, бюджеты RSS/размера, время docker stop, сохранность данных. # -# scripts/ci/smoke-image.sh [port] +# scripts/ci/smoke-image.sh +# +# Запросы идут ВНУТРИ контейнера (docker exec + busybox wget): раннер CI сам работает +# в контейнере, и опубликованный порт хоста с 127.0.0.1 задания недостижим. # # Бюджеты (env): MAX_IMAGE_MB (250), MAX_RSS_MB (200), MAX_START_S (15), MAX_STOP_S (5) set -euo pipefail -IMAGE="${1:?usage: smoke-image.sh [port]}" -PORT="${2:-18080}" +IMAGE="${1:?usage: smoke-image.sh }" MAX_IMAGE_MB="${MAX_IMAGE_MB:-250}" MAX_RSS_MB="${MAX_RSS_MB:-200}" MAX_START_S="${MAX_START_S:-15}" @@ -18,7 +20,7 @@ NAME="authportal-smoke-$$" VOLUME="authportal-smoke-data-$$" SECRET="smoke-jwt-secret-0123456789" ADMIN_PW="smoke-admin-pass-1" -BASE="http://127.0.0.1:${PORT}" +BASE="http://127.0.0.1:8080" fail() { echo "SMOKE FAIL: $*" >&2; docker logs --tail 50 "$NAME" >&2 || true; exit 1; } cleanup() { @@ -27,11 +29,22 @@ cleanup() { } trap cleanup EXIT +# GET (или POST с JSON-телом) изнутри контейнера; тело ответа в stdout. +req() { + local url="$1" body="${2:-}" + if [ -n "$body" ]; then + docker exec "$NAME" wget -q -O - -T 5 \ + --header='content-type: application/json' --post-data="$body" "$url" + else + docker exec "$NAME" wget -q -O - -T 5 "$url" + fi +} + run_container() { docker run -d --name "$NAME" --init \ --memory 256m --memory-swap 256m --cpus 1.0 --pids-limit 128 \ --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges:true \ - -v "$VOLUME:/data" -p "${PORT}:8080" \ + -v "$VOLUME:/data" \ -e JWT_SECRET="$SECRET" -e ADMIN_PASSWORD="$ADMIN_PW" \ -e ADMIN_EMAIL=admin@smoke.local -e ISSUER="$BASE" \ "$IMAGE" >/dev/null @@ -41,7 +54,7 @@ wait_ready() { local start deadline start=$(date +%s) deadline=$((start + MAX_START_S)) - until curl -fsS -m 2 "$BASE/ready" >/dev/null 2>&1; do + until req "$BASE/ready" >/dev/null 2>&1; do [ "$(date +%s)" -lt "$deadline" ] || fail "/ready не ответил за ${MAX_START_S}s" sleep 0.3 done @@ -49,9 +62,12 @@ wait_ready() { } login() { - curl -fsS -m 10 -X POST "$BASE/api/v1/auth/login" \ - -H 'content-type: application/json' \ - -d "{\"email\":\"admin@smoke.local\",\"password\":\"${ADMIN_PW}\"}" + req "$BASE/api/v1/auth/login" \ + "{\"email\":\"admin@smoke.local\",\"password\":\"${ADMIN_PW}\"}" +} + +jwks_kid() { + req "$BASE/.well-known/jwks.json" | sed -E 's/.*"kid":"([^"]+)".*/\1/' } # 1. размер образа @@ -61,19 +77,21 @@ echo "image size: ${size_mb} MiB (budget ${MAX_IMAGE_MB})" # 2. старт, ready, логин, JWKS docker volume create "$VOLUME" >/dev/null -docker run --rm -v "$VOLUME:/data" --entrypoint sh "$IMAGE" -c 'chown 1000:1000 /data' >/dev/null 2>&1 || true +docker run --rm -v "$VOLUME:/data" --user root --entrypoint sh "$IMAGE" -c 'chown 1000:1000 /data' >/dev/null 2>&1 || true run_container wait_ready -[ "$(curl -fsS -m 3 "$BASE/health")" = '{"ok":true}' ] || fail "/health" +[ "$(req "$BASE/health")" = '{"ok":true}' ] || fail "/health" login | grep -q '"access_token"' || fail "логин администратора" -kid1=$(curl -fsS -m 3 "$BASE/.well-known/jwks.json" | sed -E 's/.*"kid":"([^"]+)".*/\1/') +kid1=$(jwks_kid) [ -n "$kid1" ] || fail "JWKS пуст" -code=$(curl -s -m 3 -o /dev/null -w '%{http_code}' "$BASE/oauth/nope") -[ "$code" = 404 ] || fail "/oauth/nope вернул $code, ожидался 404" +docker exec "$NAME" wget -S -O /dev/null -T 5 "$BASE/oauth/nope" 2>&1 | grep -q "HTTP/1.[01] 404" \ + || fail "/oauth/nope: ожидался 404" # 3. память под нагрузкой логинов for _ in $(seq 1 10); do login >/dev/null; done -rss_mb=$(docker stats --no-stream --format '{{.MemUsage}}' "$NAME" | sed -E 's/^([0-9.]+)([KMG]i?B).*/\1 \2/' | awk '{u=$2; v=$1; if(u ~ /^G/) v*=1024; else if(u ~ /^K/) v/=1024; printf "%d", v}') +rss_mb=$(docker stats --no-stream --format '{{.MemUsage}}' "$NAME" \ + | sed -E 's/^([0-9.]+)([KMG]i?B).*/\1 \2/' \ + | awk '{u=$2; v=$1; if(u ~ /^G/) v*=1024; else if(u ~ /^K/) v/=1024; printf "%d", v}') echo "RSS: ${rss_mb} MiB (budget ${MAX_RSS_MB})" [ "$rss_mb" -le "$MAX_RSS_MB" ] || fail "RSS ${rss_mb} MiB > ${MAX_RSS_MB} MiB" @@ -90,7 +108,7 @@ docker rm "$NAME" >/dev/null run_container wait_ready login | grep -q '"access_token"' || fail "логин после рестарта" -kid2=$(curl -fsS -m 3 "$BASE/.well-known/jwks.json" | sed -E 's/.*"kid":"([^"]+)".*/\1/') +kid2=$(jwks_kid) [ "$kid1" = "$kid2" ] || fail "kid изменился после рестарта ($kid1 → $kid2)" echo "SMOKE OK"