fix(mcp): подготовить доступ и отзыв разрешений для приёмки
This commit is contained in:
commit
ef7bb79d4e
311 files changed
+73119
No files matched your search
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env sh
|
||||
# Абсолютные пути для actions/cache: на act_runner «~» часто не раскрывается → cache miss.
|
||||
set -eu
|
||||
: "${GITHUB_ENV:?GITHUB_ENV required (Gitea/GitHub Actions)}"
|
||||
|
||||
HOME_DIR="${HOME:-}"
|
||||
if [ -z "$HOME_DIR" ]; then
|
||||
HOME_DIR="$(getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6 || true)"
|
||||
fi
|
||||
HOME_DIR="${HOME_DIR:-/root}"
|
||||
|
||||
PNPM_STORE_DIR="${HOME_DIR}/.pnpm-store"
|
||||
COREPACK_HOME="${HOME_DIR}/.cache/node/corepack"
|
||||
|
||||
mkdir -p "$PNPM_STORE_DIR" "$COREPACK_HOME"
|
||||
|
||||
{
|
||||
echo "HOME_DIR=${HOME_DIR}"
|
||||
echo "PNPM_STORE_DIR=${PNPM_STORE_DIR}"
|
||||
echo "COREPACK_HOME=${COREPACK_HOME}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
echo "cache-env HOME_DIR=${HOME_DIR}"
|
||||
echo "cache-env PNPM_STORE_DIR=${PNPM_STORE_DIR}"
|
||||
echo "cache-env COREPACK_HOME=${COREPACK_HOME}"
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env sh
|
||||
# pnpm install из кэша Gitea (store + node_modules). Без повторного download при hit.
|
||||
set -eu
|
||||
: "${PNPM_STORE_DIR:?PNPM_STORE_DIR required — run scripts/ci/export-cache-env.sh first}"
|
||||
|
||||
export HUSKY=0
|
||||
export COREPACK_HOME="${COREPACK_HOME:-${HOME:-/root}/.cache/node/corepack}"
|
||||
mkdir -p "$PNPM_STORE_DIR" "$COREPACK_HOME"
|
||||
corepack enable
|
||||
pnpm config set store-dir "$PNPM_STORE_DIR"
|
||||
|
||||
echo "pnpm store: $(pnpm store path)"
|
||||
echo "PNPM_CACHE_HIT=${PNPM_CACHE_HIT:-}"
|
||||
|
||||
if [ "${PNPM_CACHE_HIT:-}" = "true" ]; then
|
||||
if pnpm install --frozen-lockfile --offline; then
|
||||
echo "pnpm install --offline (cache hit)"
|
||||
exit 0
|
||||
fi
|
||||
echo "offline install failed — prefer-offline"
|
||||
fi
|
||||
pnpm install --frozen-lockfile --prefer-offline
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke-тест собранного образа: запуск с боевыми ограничениями (как в compose),
|
||||
# /ready, логин, JWKS, бюджеты RSS/размера, время docker stop, сохранность данных.
|
||||
#
|
||||
# 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)
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${1:?usage: smoke-image.sh <image>}"
|
||||
MAX_IMAGE_MB="${MAX_IMAGE_MB:-250}"
|
||||
MAX_RSS_MB="${MAX_RSS_MB:-200}"
|
||||
MAX_START_S="${MAX_START_S:-15}"
|
||||
MAX_STOP_S="${MAX_STOP_S:-5}"
|
||||
|
||||
NAME="authportal-smoke-$$"
|
||||
VOLUME="authportal-smoke-data-$$"
|
||||
SECRET="smoke-jwt-secret-0123456789"
|
||||
ADMIN_PW="smoke-admin-pass-1"
|
||||
BASE="http://127.0.0.1:8080"
|
||||
|
||||
fail() { echo "SMOKE FAIL: $*" >&2; docker logs --tail 50 "$NAME" >&2 || true; exit 1; }
|
||||
cleanup() {
|
||||
docker rm -f "$NAME" >/dev/null 2>&1 || true
|
||||
docker volume rm -f "$VOLUME" >/dev/null 2>&1 || true
|
||||
}
|
||||
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" \
|
||||
-e JWT_SECRET="$SECRET" -e ADMIN_PASSWORD="$ADMIN_PW" \
|
||||
-e ADMIN_EMAIL=admin@smoke.local -e ISSUER="$BASE" \
|
||||
"$IMAGE" >/dev/null
|
||||
}
|
||||
|
||||
wait_ready() {
|
||||
local start deadline
|
||||
start=$(date +%s)
|
||||
deadline=$((start + MAX_START_S))
|
||||
until req "$BASE/ready" >/dev/null 2>&1; do
|
||||
[ "$(date +%s)" -lt "$deadline" ] || fail "/ready не ответил за ${MAX_START_S}s"
|
||||
sleep 0.3
|
||||
done
|
||||
echo "ready за $(( $(date +%s) - start ))s"
|
||||
}
|
||||
|
||||
login() {
|
||||
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. размер образа
|
||||
size_mb=$(( $(docker image inspect "$IMAGE" --format '{{.Size}}') / 1024 / 1024 ))
|
||||
echo "image size: ${size_mb} MiB (budget ${MAX_IMAGE_MB})"
|
||||
[ "$size_mb" -le "$MAX_IMAGE_MB" ] || fail "образ ${size_mb} MiB > ${MAX_IMAGE_MB} MiB"
|
||||
|
||||
# 2. старт, ready, логин, JWKS
|
||||
docker volume create "$VOLUME" >/dev/null
|
||||
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
|
||||
[ "$(req "$BASE/health")" = '{"ok":true}' ] || fail "/health"
|
||||
login | grep -q '"access_token"' || fail "логин администратора"
|
||||
kid1=$(jwks_kid)
|
||||
[ -n "$kid1" ] || fail "JWKS пуст"
|
||||
# wget на 404 завершается с ошибкой (pipefail!) — собираем вывод отдельно.
|
||||
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. память под нагрузкой логинов
|
||||
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}')
|
||||
echo "RSS: ${rss_mb} MiB (budget ${MAX_RSS_MB})"
|
||||
[ "$rss_mb" -le "$MAX_RSS_MB" ] || fail "RSS ${rss_mb} MiB > ${MAX_RSS_MB} MiB"
|
||||
|
||||
# 4. быстрый graceful stop
|
||||
t0=$(date +%s.%N)
|
||||
docker stop -t 15 "$NAME" >/dev/null
|
||||
elapsed=$(awk -v a="$t0" -v b="$(date +%s.%N)" 'BEGIN{printf "%.1f", b-a}')
|
||||
echo "docker stop: ${elapsed}s (budget ${MAX_STOP_S}s)"
|
||||
awk -v e="$elapsed" -v m="$MAX_STOP_S" 'BEGIN{exit !(e<=m)}' || fail "docker stop ${elapsed}s > ${MAX_STOP_S}s"
|
||||
[ "$(docker inspect "$NAME" --format '{{.State.ExitCode}}')" = 0 ] || fail "код выхода после SIGTERM не 0"
|
||||
|
||||
# 5. данные переживают рестарт (тот же ключ подписи и тот же админ)
|
||||
docker rm "$NAME" >/dev/null
|
||||
run_container
|
||||
wait_ready
|
||||
login | grep -q '"access_token"' || fail "логин после рестарта"
|
||||
kid2=$(jwks_kid)
|
||||
[ "$kid1" = "$kid2" ] || fail "kid изменился после рестарта ($kid1 → $kid2)"
|
||||
|
||||
echo "SMOKE OK"
|
||||
@@ -0,0 +1,48 @@
|
||||
// Бюджет размера web-бандла: node scripts/ci/web-budget.mjs [dist/assets]
|
||||
// Лимиты — gzip-байты; переопределяются env WEB_BUDGET_ENTRY_GZ / WEB_BUDGET_TOTAL_GZ.
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { gzipSync } from 'node:zlib'
|
||||
|
||||
export const DEFAULT_LIMITS = {
|
||||
entryGz: 25 * 1024,
|
||||
totalGz: 560 * 1024,
|
||||
}
|
||||
|
||||
export function measure(dir) {
|
||||
let total = 0
|
||||
let entry = 0
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (!name.endsWith('.js')) continue
|
||||
const gz = gzipSync(readFileSync(join(dir, name))).length
|
||||
total += gz
|
||||
if (/^index-.*\.js$/.test(name)) entry = Math.max(entry, gz)
|
||||
}
|
||||
return { entryGz: entry, totalGz: total }
|
||||
}
|
||||
|
||||
export function checkBudget(dir, limits = DEFAULT_LIMITS) {
|
||||
const sizes = measure(dir)
|
||||
const errors = []
|
||||
if (sizes.entryGz === 0) errors.push('entry chunk index-*.js не найден')
|
||||
if (sizes.entryGz > limits.entryGz)
|
||||
errors.push(`entry gzip ${sizes.entryGz} > ${limits.entryGz}`)
|
||||
if (sizes.totalGz > limits.totalGz)
|
||||
errors.push(`total gzip ${sizes.totalGz} > ${limits.totalGz}`)
|
||||
return { sizes, errors }
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const dir = process.argv[2] ?? 'apps/web/dist/assets'
|
||||
const limits = {
|
||||
entryGz: Number(process.env.WEB_BUDGET_ENTRY_GZ ?? DEFAULT_LIMITS.entryGz),
|
||||
totalGz: Number(process.env.WEB_BUDGET_TOTAL_GZ ?? DEFAULT_LIMITS.totalGz),
|
||||
}
|
||||
const { sizes, errors } = checkBudget(dir, limits)
|
||||
console.log('web bundle (gzip):', sizes, 'limits:', limits)
|
||||
if (errors.length) {
|
||||
console.error('BUDGET EXCEEDED:\n - ' + errors.join('\n - '))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Warns about commits since the last tag that semantic-release cannot parse.
|
||||
* Exit 0 always — semantic-release still decides release/no-op.
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import { CommitParser } from 'conventional-commits-parser';
|
||||
|
||||
const parser = new CommitParser();
|
||||
|
||||
const RELEASABLE = new Set(['feat', 'fix', 'perf', 'ci', 'refactor']);
|
||||
|
||||
function lastTag() {
|
||||
try {
|
||||
return execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function commitsSince(ref) {
|
||||
const range = ref ? `${ref}..HEAD` : 'HEAD';
|
||||
const out = execSync(`git log ${range} --format=%H%x09%s`, { encoding: 'utf8' }).trim();
|
||||
if (!out) return [];
|
||||
return out.split('\n').map((line) => {
|
||||
const [hash, subject] = line.split('\t');
|
||||
return { hash: hash.trim(), subject: subject.trim() };
|
||||
});
|
||||
}
|
||||
|
||||
const tag = lastTag();
|
||||
const commits = commitsSince(tag);
|
||||
const unparseable = [];
|
||||
const releasable = [];
|
||||
|
||||
for (const { hash, subject } of commits) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parser.parse(subject);
|
||||
} catch {
|
||||
unparseable.push({ hash: hash.slice(0, 7), subject });
|
||||
continue;
|
||||
}
|
||||
if (!parsed?.type) {
|
||||
unparseable.push({ hash: hash.slice(0, 7), subject });
|
||||
continue;
|
||||
}
|
||||
if (RELEASABLE.has(parsed.type)) {
|
||||
releasable.push({ hash: hash.slice(0, 7), subject, type: parsed.type });
|
||||
}
|
||||
}
|
||||
|
||||
if (commits.length === 0) {
|
||||
console.log(`No new commits since ${tag || 'initial'}.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (unparseable.length > 0) {
|
||||
console.warn('::warning:: Commits not parseable by semantic-release (no version bump):');
|
||||
for (const b of unparseable) {
|
||||
console.warn(` ${b.hash} ${b.subject}`);
|
||||
}
|
||||
console.warn('Fix: single scope without commas, e.g. refactor(web): summary');
|
||||
}
|
||||
|
||||
if (releasable.length > 0) {
|
||||
console.log(`Releasable since ${tag}: ${releasable.length} commit(s).`);
|
||||
} else {
|
||||
console.warn('::warning:: No releasable commits since last tag — release job will no-op.');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,73 @@
|
||||
// Нагрузочный smoke по запущенному образу/серверу (autocannon).
|
||||
// node scripts/perf/load.mjs http://127.0.0.1:8080 [container-name]
|
||||
// Бюджеты (env): LOAD_MAX_P99_MS (150), LOAD_MIN_RPS (500), LOAD_MAX_RSS_MB (200), LOAD_DURATION_S (15)
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import autocannon from 'autocannon'
|
||||
|
||||
const base = process.argv[2] ?? 'http://127.0.0.1:8080'
|
||||
const container = process.argv[3]
|
||||
const duration = Number(process.env.LOAD_DURATION_S ?? 15)
|
||||
const maxP99 = Number(process.env.LOAD_MAX_P99_MS ?? 150)
|
||||
const minRps = Number(process.env.LOAD_MIN_RPS ?? 500)
|
||||
const maxRss = Number(process.env.LOAD_MAX_RSS_MB ?? 200)
|
||||
const email = process.env.LOAD_EMAIL ?? 'admin@smoke.local'
|
||||
const password = process.env.LOAD_PASSWORD
|
||||
|
||||
async function token() {
|
||||
if (!password) return null
|
||||
const res = await fetch(`${base}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`login ${res.status}`)
|
||||
return (await res.json()).access_token
|
||||
}
|
||||
|
||||
function run(title, opts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
autocannon({ url: base, connections: 50, duration, ...opts }, (err, r) =>
|
||||
err ? reject(err) : resolve({ title, r }),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function rssMb() {
|
||||
if (!container) return null
|
||||
const out = execFileSync('docker', ['stats', '--no-stream', '--format', '{{.MemUsage}}', container]).toString()
|
||||
const m = /^([\d.]+)(KiB|MiB|GiB)/.exec(out.trim())
|
||||
if (!m) return null
|
||||
const v = Number(m[1])
|
||||
return m[2] === 'GiB' ? v * 1024 : m[2] === 'KiB' ? v / 1024 : v
|
||||
}
|
||||
|
||||
const jwt = await token()
|
||||
const scenarios = [{ title: 'GET /health', opts: { url: `${base}/health` } }]
|
||||
if (jwt) {
|
||||
scenarios.push({
|
||||
title: 'GET /api/v1/auth/me',
|
||||
opts: { url: `${base}/api/v1/auth/me`, headers: { authorization: `Bearer ${jwt}` } },
|
||||
})
|
||||
}
|
||||
|
||||
const failures = []
|
||||
for (const s of scenarios) {
|
||||
const { r } = await run(s.title, s.opts)
|
||||
const non2xx = r.non2xx
|
||||
console.log(
|
||||
`${s.title}: ${Math.round(r.requests.average)} rps, p99 ${r.latency.p99} ms, non2xx ${non2xx}, errors ${r.errors}`,
|
||||
)
|
||||
if (r.latency.p99 > maxP99) failures.push(`${s.title}: p99 ${r.latency.p99} > ${maxP99} ms`)
|
||||
if (r.requests.average < minRps) failures.push(`${s.title}: ${Math.round(r.requests.average)} rps < ${minRps}`)
|
||||
if (non2xx > 0 || r.errors > 0) failures.push(`${s.title}: ${non2xx} non2xx, ${r.errors} errors`)
|
||||
}
|
||||
const rss = rssMb()
|
||||
if (rss != null) {
|
||||
console.log(`RSS после нагрузки: ${Math.round(rss)} MiB`)
|
||||
if (rss > maxRss) failures.push(`RSS ${Math.round(rss)} MiB > ${maxRss} MiB`)
|
||||
}
|
||||
if (failures.length) {
|
||||
console.error('LOAD BUDGET FAILED:\n - ' + failures.join('\n - '))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('LOAD OK')
|
||||
Reference in new issue
Block a user