Author SHA1 Message Date
Denozordec c3e8ccf7c7 fix(ci): smoke — проверка 404 не должна падать из-за pipefail
quality / changes (push) Successful in 6s
quality / commitlint (push) Skipped
quality / web (push) Successful in 41s
quality / api (push) Successful in 48s
quality / docker-check (push) Successful in 1m6s
CD / quality (push) Successful in 2m44s
CD / publish (push) Successful in 1m54s
2026-10-23 17:57:42 +07:00
Denozordec 7cec86400b fix(ci): восстановить nightly-perf.yaml (контейнерная нагрузка, RSS отдельным шагом)
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / web (push) Successful in 45s
quality / api (push) Successful in 53s
quality / docker-check (push) Failing after 27s
CD / quality (push) Failing after 2m14s
CD / publish (push) Skipped
2026-10-23 17:54:09 +07:00
Denozordec eb48c83c7e fix(ci): smoke и nightly обращаются к контейнеру изнутри (раннер сам в контейнере)
nightly-perf.yaml (push) Invalid workflow file
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / docker-check (push) Canceled after 0s
quality / api (push) Canceled after 0s
quality / web (push) Canceled after 25s
CD / quality (push) Canceled after 34s
CD / publish (push) Skipped
2026-10-23 17:53:51 +07:00
Denozordec 60645d5773 fix(docker): копировать весь dist API и отключить splitting в tsup (ERR_MODULE_NOT_FOUND на chunk)
quality / changes (push) Successful in 6s
quality / web (push) Skipped
quality / commitlint (push) Skipped
quality / api (push) Successful in 52s
quality / docker-check (push) Failing after 1m7s
CD / quality (push) Failing after 2m8s
CD / publish (push) Skipped
2026-10-23 17:45:36 +07:00
5 changed files with 67 additions and 30 deletions

No files matched your search

+23 -12
View File
@@ -16,8 +16,6 @@ jobs:
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- id: setup
uses: ./.gitea/actions/setup-pnpm
- name: Build image - name: Build image
working-directory: deploy/docker working-directory: deploy/docker
env: env:
@@ -30,20 +28,33 @@ jobs:
run: | run: |
set -euxo pipefail set -euxo pipefail
docker volume create load-data docker volume create load-data
docker run --rm -v load-data:/data --entrypoint sh authportal:load -c 'chown 1000:1000 /data' 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 \ 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 \ --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 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 -e ADMIN_EMAIL=admin@smoke.local -e ISSUER=http://127.0.0.1:8080 \
for i in $(seq 1 40); do curl -fsS http://127.0.0.1:18080/ready && break; sleep 0.5; done -e RATE_LIMIT_MAX=1000000 authportal:load
- name: Load test for i in $(seq 1 40); do
env: docker exec authportal-load wget -q -O /dev/null http://127.0.0.1:8080/ready && break
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }} sleep 0.5
LOAD_PASSWORD: load-admin-pass-1 done
# Раннер сам работает в контейнере: нагрузку гоним из соседнего контейнера
# в сетевом namespace приложения (127.0.0.1:8080 — это приложение).
- name: Load test (autocannon в namespace приложения)
run: | run: |
set -euxo pipefail set -euxo pipefail
sh scripts/ci/pnpm-ci.sh docker run --rm -i --network container:authportal-load \
node scripts/perf/load.mjs http://127.0.0.1:18080 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).*/\1 \2/' \
| 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 - name: Container logs on failure
if: failure() if: failure()
run: docker logs --tail 100 authportal-load || true run: docker logs --tail 100 authportal-load || true
+5
View File
@@ -48,6 +48,11 @@ describe('Dockerfile', () => {
expect(read('deploy/docker/docker-bake.hcl')).toMatch(/node:22-alpine@sha256:[0-9a-f]{64}/) expect(read('deploy/docker/docker-bake.hcl')).toMatch(/node:22-alpine@sha256:[0-9a-f]{64}/)
}) })
it('в образ копируется весь dist API, а tsup не дробит бандл на чанки', () => {
expect(dockerfile).toContain('cp -r apps/api/dist /out/dist')
expect(read('apps/api/tsup.config.ts')).toMatch(/splitting:\s*false/)
})
it('нативные модули проверяются при сборке', () => { it('нативные модули проверяются при сборке', () => {
expect(dockerfile).toContain('native modules ok') expect(dockerfile).toContain('native modules ok')
}) })
+2
View File
@@ -21,5 +21,7 @@ export default defineConfig({
'const __dirname = __dn(__filename);', 'const __dirname = __dn(__filename);',
].join('\n'), ].join('\n'),
}, },
// Один файл: динамические import() (напр. CLI backup) не должны порождать чанки.
splitting: false,
clean: true, clean: true,
}) })
+2 -2
View File
@@ -31,8 +31,8 @@ RUN rm -f apps/web/src/routes/design-preview*.tsx
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm turbo build --filter=web --filter=@authportal/api pnpm turbo build --filter=web --filter=@authportal/api
# Рантайм-слой: бандл + статика (только brotli) + нативные модули с теми же версиями, что в lockfile. # Рантайм-слой: бандл + статика (только brotli) + нативные модули с теми же версиями, что в lockfile.
RUN mkdir -p /out/dist \ RUN mkdir -p /out \
&& cp apps/api/dist/server.js /out/dist/server.js \ && cp -r apps/api/dist /out/dist \
&& cp -r apps/web/dist /out/static \ && cp -r apps/web/dist /out/static \
&& find /out/static -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' \) \ && find /out/static -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' \) \
-exec sh -c 'brotli -f -q 11 -o "$1.br" "$1"' _ {} \; \ -exec sh -c 'brotli -f -q 11 -o "$1.br" "$1"' _ {} \; \
+35 -16
View File
@@ -2,13 +2,15 @@
# Smoke-тест собранного образа: запуск с боевыми ограничениями (как в compose), # Smoke-тест собранного образа: запуск с боевыми ограничениями (как в compose),
# /ready, логин, JWKS, бюджеты RSS/размера, время docker stop, сохранность данных. # /ready, логин, JWKS, бюджеты RSS/размера, время docker stop, сохранность данных.
# #
# scripts/ci/smoke-image.sh <image> [port] # scripts/ci/smoke-image.sh <image>
#
# Запросы идут ВНУТРИ контейнера (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) # Бюджеты (env): MAX_IMAGE_MB (250), MAX_RSS_MB (200), MAX_START_S (15), MAX_STOP_S (5)
set -euo pipefail set -euo pipefail
IMAGE="${1:?usage: smoke-image.sh <image> [port]}" IMAGE="${1:?usage: smoke-image.sh <image>}"
PORT="${2:-18080}"
MAX_IMAGE_MB="${MAX_IMAGE_MB:-250}" MAX_IMAGE_MB="${MAX_IMAGE_MB:-250}"
MAX_RSS_MB="${MAX_RSS_MB:-200}" MAX_RSS_MB="${MAX_RSS_MB:-200}"
MAX_START_S="${MAX_START_S:-15}" MAX_START_S="${MAX_START_S:-15}"
@@ -18,7 +20,7 @@ NAME="authportal-smoke-$$"
VOLUME="authportal-smoke-data-$$" VOLUME="authportal-smoke-data-$$"
SECRET="smoke-jwt-secret-0123456789" SECRET="smoke-jwt-secret-0123456789"
ADMIN_PW="smoke-admin-pass-1" 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; } fail() { echo "SMOKE FAIL: $*" >&2; docker logs --tail 50 "$NAME" >&2 || true; exit 1; }
cleanup() { cleanup() {
@@ -27,11 +29,22 @@ cleanup() {
} }
trap cleanup EXIT 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() { run_container() {
docker run -d --name "$NAME" --init \ docker run -d --name "$NAME" --init \
--memory 256m --memory-swap 256m --cpus 1.0 --pids-limit 128 \ --memory 256m --memory-swap 256m --cpus 1.0 --pids-limit 128 \
--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges:true \ --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 JWT_SECRET="$SECRET" -e ADMIN_PASSWORD="$ADMIN_PW" \
-e ADMIN_EMAIL=admin@smoke.local -e ISSUER="$BASE" \ -e ADMIN_EMAIL=admin@smoke.local -e ISSUER="$BASE" \
"$IMAGE" >/dev/null "$IMAGE" >/dev/null
@@ -41,7 +54,7 @@ wait_ready() {
local start deadline local start deadline
start=$(date +%s) start=$(date +%s)
deadline=$((start + MAX_START_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" [ "$(date +%s)" -lt "$deadline" ] || fail "/ready не ответил за ${MAX_START_S}s"
sleep 0.3 sleep 0.3
done done
@@ -49,9 +62,12 @@ wait_ready() {
} }
login() { login() {
curl -fsS -m 10 -X POST "$BASE/api/v1/auth/login" \ req "$BASE/api/v1/auth/login" \
-H 'content-type: application/json' \ "{\"email\":\"admin@smoke.local\",\"password\":\"${ADMIN_PW}\"}"
-d "{\"email\":\"admin@smoke.local\",\"password\":\"${ADMIN_PW}\"}" }
jwks_kid() {
req "$BASE/.well-known/jwks.json" | sed -E 's/.*"kid":"([^"]+)".*/\1/'
} }
# 1. размер образа # 1. размер образа
@@ -61,19 +77,22 @@ echo "image size: ${size_mb} MiB (budget ${MAX_IMAGE_MB})"
# 2. старт, ready, логин, JWKS # 2. старт, ready, логин, JWKS
docker volume create "$VOLUME" >/dev/null 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 run_container
wait_ready 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 "логин администратора" 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 пуст" [ -n "$kid1" ] || fail "JWKS пуст"
code=$(curl -s -m 3 -o /dev/null -w '%{http_code}' "$BASE/oauth/nope") # wget на 404 завершается с ошибкой (pipefail!) — собираем вывод отдельно.
[ "$code" = 404 ] || fail "/oauth/nope вернул $code, ожидался 404" not_found=$(docker exec "$NAME" wget -S -O /dev/null -T 5 "$BASE/oauth/nope" 2>&1 || true)
echo "$not_found" | grep -q "HTTP/1.[01] 404" || fail "/oauth/nope: ожидался 404, получено: $not_found"
# 3. память под нагрузкой логинов # 3. память под нагрузкой логинов
for _ in $(seq 1 10); do login >/dev/null; done 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})" echo "RSS: ${rss_mb} MiB (budget ${MAX_RSS_MB})"
[ "$rss_mb" -le "$MAX_RSS_MB" ] || fail "RSS ${rss_mb} MiB > ${MAX_RSS_MB} MiB" [ "$rss_mb" -le "$MAX_RSS_MB" ] || fail "RSS ${rss_mb} MiB > ${MAX_RSS_MB} MiB"
@@ -90,7 +109,7 @@ docker rm "$NAME" >/dev/null
run_container run_container
wait_ready wait_ready
login | grep -q '"access_token"' || fail "логин после рестарта" 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)" [ "$kid1" = "$kid2" ] || fail "kid изменился после рестарта ($kid1 → $kid2)"
echo "SMOKE OK" echo "SMOKE OK"