#!/usr/bin/env bash # Полная регрессия main всех репозиториев workspace перед выкладкой (arm-ka-docs#179). Описание — README.md рядом. # # scripts/regression/regress.sh [--out DIR] [--only st1,st2] [--skip st1,st2] [--keep-stand] [--allow-dirty] # [--bank-baseline DIR] [--list] # # Этапы идут по порядку; упавший шаг не останавливает следующие (нужна вся картина), кроме preflight: без чистых # main = origin/main прогон не имеет смысла (--allow-dirty — прогнать как есть, с пометкой в итоге). # Итог — $OUT/summary.md (SHA репозиториев, статус и время шагов, счётчики pytest, пропуски), логи — $OUT/logs/. # Код возврата: 0 — все шаги зелёные, 1 — есть красные, 2 — preflight не пройден или неверные аргументы. # # Workspace (каталог с arm-core/, tabular-parser/, … на main) — ARM_KA_WORKSPACE, по умолчанию родитель docs. Скрипты # можно запускать из worktree docs: тогда задайте ARM_KA_WORKSPACE, проверяются всё равно основные checkout'ы. set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" WS="${ARM_KA_WORKSPACE:-$(cd "$HERE/../../.." && pwd)}" COMPOSE_FILE="$WS/docs/dev/docker-compose.yml" # Изолированный стенд: свой compose-проект и порты, общий стенд arm-ka (данные, тома) не трогается REGRESS_PROJECT="${REGRESS_PROJECT:-arm-ka-regress}" # Порты — только для команд этого проекта (dcr): глобальный export пересоздал бы контейнеры arm-ka на чужих портах ARM_CORE_PORT=18000 REGRESS_PORTS="POSTGRES_PORT=15432 NATS_PORT=14222 NATS_MONITOR_PORT=18222 MINIO_API_PORT=19000 MINIO_CONSOLE_PORT=19001 ARM_CORE_PORT=$ARM_CORE_PORT TABULAR_PARSER_PORT=18001 SCAN_GATEWAY_PORT=18002" ALL_STAGES="preflight venv static web integration fixtures images e2e stand ui bank" # Репозитории под проверкой; pylib_ai_arm_statements (AURORA-1677) появится здесь после выпуска 0.1.2 REPOS="docs fixtures arm-statements arm-core tabular-parser scan-gateway" OUT="" ONLY="" SKIP="" KEEP_STAND=0 ALLOW_DIRTY=0 BANK_BASELINE="" while [ $# -gt 0 ]; do case "$1" in --out) OUT="$2"; shift 2 ;; --only) ONLY="$2"; shift 2 ;; --skip) SKIP="$2"; shift 2 ;; --keep-stand) KEEP_STAND=1; shift ;; --allow-dirty) ALLOW_DIRTY=1; shift ;; --bank-baseline) BANK_BASELINE="$2"; shift 2 ;; --list) echo "$ALL_STAGES"; exit 0 ;; -h | --help) sed -n '2,13p' "$0"; exit 0 ;; *) echo "неизвестный аргумент: $1" >&2; exit 2 ;; esac done for s in ${ONLY//,/ } ${SKIP//,/ }; do case " $ALL_STAGES " in *" $s "*) ;; *) echo "неизвестный этап: $s (есть: $ALL_STAGES)" >&2; exit 2 ;; esac done OUT="${OUT:-$WS/admin/regression/$(date +%Y-%m-%d_%H%M)}" mkdir -p "$OUT/logs" RESULTS="$OUT/results.tsv" export ARM_KA_DOCS="$WS/docs" ARM_KA_FIXTURES="$WS/fixtures" # ENV_FILE в окружении перебивает pytest.ini сервисов — не даём просочиться из оболочки unset ENV_FILE log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; } # step <этап> <шаг> <команда…>: вывод — в logs/<этап>.<шаг>.log, строка итога — в results.tsv step() { local stage=$1 name=$2 t0 rc lf shift 2 lf="$OUT/logs/$stage.$name.log" t0=$(date +%s) log "$stage/$name …" ("$@") >"$lf" 2>&1 rc=$? printf '%s\t%s\t%s\t%s\t%s\n' "$stage" "$name" "$rc" "$(($(date +%s) - t0))" "$lf" >>"$RESULTS" if [ $rc -eq 0 ]; then log "$stage/$name ok"; else log "$stage/$name КРАСНЫЙ (rc=$rc, $lf)"; fi return $rc } want() { local s=$1 [ -n "$ONLY" ] && { case ",$ONLY," in *",$s,"*) ;; *) return 1 ;; esac; } [ -n "$SKIP" ] && { case ",$SKIP," in *",$s,"*) return 1 ;; esac; } return 0 } dc() { docker compose -f "$COMPOSE_FILE" "$@"; } # shellcheck disable=SC2086 — REGRESS_PORTS намеренно раскладывается на слова dcr() { env $REGRESS_PORTS docker compose -f "$COMPOSE_FILE" -p "$REGRESS_PROJECT" --profile services "$@"; } mk() { make --no-print-directory -C "$WS/$1" "${@:2}"; } # --- preflight ------------------------------------------------------------------------------------------------------- repo_state() { local r=$1 dir="$WS/$1" br head origin dirty [ -d "$dir/.git" ] || [ -f "$dir/.git" ] || { echo "$r: нет репозитория в $dir"; return 1; } git -C "$dir" fetch -q origin || { echo "$r: fetch не удался"; return 1; } br=$(git -C "$dir" rev-parse --abbrev-ref HEAD) head=$(git -C "$dir" rev-parse HEAD) origin=$(git -C "$dir" rev-parse origin/main) dirty=$(git -C "$dir" status --porcelain --untracked-files=no | wc -l | tr -d ' ') printf '%s\t%s\t%s\t%s\n' "$r" "$br" "$head" "$(git -C "$dir" log -1 --format='%cs %s' HEAD | cut -c1-100)" >>"$OUT/shas.tsv" echo "$r: ветка $br, HEAD ${head:0:7}, origin/main ${origin:0:7}, изменённых файлов $dirty" [ "$br" = main ] && [ "$head" = "$origin" ] && [ "$dirty" = 0 ] } st_preflight() { : >"$OUT/shas.tsv" local bad=0 r for r in $REPOS; do step preflight "repo-$r" repo_state "$r" || bad=1; done step preflight docker docker info --format '{{.ServerVersion}}' || bad=1 step preflight tools sh -c 'command -v uv && command -v npm && command -v openspec' || bad=1 # Чужие потребители общей NATS (arm-ka): контейнеры сервисов и локальное ядро на :8000 перехватывают сообщения # queue group, интеграционные тесты и e2e тогда падают по таймауту (scan-gateway — 08.10, arm-core e2e — docstring) step preflight shared-consumers sh -c ' running=$(docker ps --filter label=com.docker.compose.project=arm-ka --format "{{.Names}}" | grep -E "arm-core-1|tabular-parser|scan-gateway|scan-emulator" || true) local8000=$(lsof -nP -iTCP:8000 -sTCP:LISTEN 2>/dev/null | tail -n +2 || true) [ -z "$running" ] && [ -z "$local8000" ] && { echo "сервисов на общей NATS нет"; exit 0; } echo "на общем стенде arm-ka работают потребители NATS — остановите их до integration/e2e:"; echo "$running"; echo "$local8000"; exit 1' || bad=1 if [ $bad -ne 0 ] && [ $ALLOW_DIRTY -eq 0 ]; then log "preflight не пройден — прогон остановлен (--allow-dirty — прогнать как есть)" return 2 fi [ $bad -ne 0 ] && echo "allow-dirty" >"$OUT/allow-dirty" return 0 } # --- окружения ------------------------------------------------------------------------------------------------------- st_venv() { local r for r in docs arm-statements tabular-parser scan-gateway arm-core; do step venv "$r" mk "$r" venv; done } # --- lint + unit (без инфраструктуры) -------------------------------------------------------------------------------- st_static() { step static docs-check mk docs check step static docs-statements-check mk docs statements-check step static arm-statements-lint mk arm-statements lint step static arm-statements-test mk arm-statements test local r for r in tabular-parser scan-gateway arm-core; do step static "$r-lint" mk "$r" lint step static "$r-test" mk "$r" test done step static arm-core-dbml-check mk arm-core dbml-check } # --- фронт arm-core/web ---------------------------------------------------------------------------------------------- # В чистой копии web/ (без node_modules и dist) во временном каталоге, main не меняется (npm install в main переписывает # package-lock.json, а node_modules main бывают собраны под другую ОС). Шаг npm-ci — воспроизводимость lock: красный, # если lock расходится с package.json. Остальные шаги идут на установке без lock (npm-install): lock собран на linux, и # по нему npm не ставит нативные пакеты другой ОС (biome, rspack, canvas — npm/cli#4828); так же ставит и контур, в # снимке lock нет (dev/contour-transfer-guide.md §3.2). WEB_TMP="" STAND_UP=0 PLAYWRIGHT="${PLAYWRIGHT:-playwright==1.55.0}" web_copy() { WEB_TMP=$(mktemp -d "${TMPDIR:-/tmp}/regress-web.XXXXXX") rsync -a --exclude node_modules --exclude dist "$WS/arm-core/web/" "$WEB_TMP/" echo "$WEB_TMP" } web_install() { rm -rf "$1/node_modules" "$1/package-lock.json" && npm --prefix "$1" install --no-audit --no-fund; } st_web() { web_copy >/dev/null local w="$WEB_TMP" echo "$w" >"$OUT/web-tmp" step web npm-ci npm --prefix "$w" ci --no-audit --no-fund --dry-run step web npm-install web_install "$w" step web biome sh -c "cd '$w' && npx biome check src" step web tsc sh -c "cd '$w' && npx tsc --noEmit" step web eslint-prettier npm --prefix "$w" run lint step web vitest npm --prefix "$w" test step web build npm --prefix "$w" run build } # --- интеграционные (общая инфраструктура arm-ka: тесты создают свои БД, рабочую armka не трогают) ---------------------- st_integration() { step integration infra-up mk scan-gateway up step integration arm-core-check-infra mk arm-core check-infra step integration tabular-parser mk tabular-parser test-integration step integration scan-gateway mk scan-gateway test-integration # включает цикл миграций upgrade → downgrade base → upgrade и сверку с create_all (tests/test_migrations.py) step integration arm-core mk arm-core test-integration } # --- реальные данные ------------------------------------------------------------------------------------------------- st_fixtures() { step fixtures tabular-parser-fixtures mk tabular-parser test-fixtures step fixtures tabular-parser-golden mk tabular-parser golden-check } # --- образы ---------------------------------------------------------------------------------------------------------- # Сборка профиля services собирает arm-core:dev, tabular-parser:dev, scan-gateway:dev из main (Dockerfile в корне репо); # эти же образы потом поднимают e2e и стенд. Эквивалент make docker-build трёх сервисов. st_images() { step images build dcr build } # --- e2e ядра с настоящим tabular-parser (общая NATS/MinIO из .env.test) --------------------------------------------- e2e_run() { local was rc was=$(docker ps -q --filter name=arm-ka-tabular-parser-1) dc -p arm-ka --profile services up -d --no-build --wait tabular-parser || return 1 mk arm-core e2e rc=$? [ -z "$was" ] && dc -p arm-ka --profile services stop tabular-parser return $rc } st_e2e() { step e2e arm-core-osv-ingest e2e_run # пропуск («профиль services не поднят») даёт код 0 — его считаем красным if grep -q "SKIPPED" "$OUT/logs/e2e.arm-core-osv-ingest.log"; then printf 'e2e\tnot-skipped\t1\t0\t%s\n' "$OUT/logs/e2e.arm-core-osv-ingest.log" >>"$RESULTS" log "e2e/not-skipped КРАСНЫЙ: e2e пропущен" fi } # prev_run <подкаталог/файл>: каталог <подкаталог> последнего прошлого прогона рядом с $OUT, где файл есть (база сравнения) prev_run() { local f f=$(find "$(dirname "$OUT")" -mindepth 3 -maxdepth 3 -path "*/$1" ! -path "$OUT/*" 2>/dev/null | sort | tail -1) [ -n "$f" ] && dirname "$f" } # --- изолированный стенд с нуля: миграции, seed --dev, сценарий по API ----------------------------------------------- stand_down() { dcr down -v --remove-orphans; } st_stand() { local stand_base stand_base=$(prev_run stand/report.json) step stand down-before stand_down step stand up dcr up -d --no-build --wait \ postgres nats minio arm-core tabular-parser scan-gateway scan-emulator step stand scenario python3 -I "$HERE/stand_scenario.py" --api "http://127.0.0.1:$ARM_CORE_PORT" \ --fixtures "$WS/fixtures" --expected "$HERE/expected.json" --out "$OUT/stand" \ ${stand_base:+--baseline "$stand_base"} STAND_UP=1 } # Стенд снимается после ui (экраны смотрят на его данные); --keep-stand — оставить для ручной проверки stand_teardown() { [ "$STAND_UP" = 1 ] || return 0 dcr logs --no-color >"$OUT/logs/stand.compose.log" 2>&1 if [ $KEEP_STAND -eq 1 ]; then log "стенд оставлен: проект $REGRESS_PROJECT, ядро http://127.0.0.1:$ARM_CORE_PORT (снять: docker compose -p $REGRESS_PROJECT --profile services down -v)" else step stand down-after stand_down fi } # --- UI-смоук: dev-сервер фронта (копия web/ из этапа web) против стенда, Playwright по основным экранам -------------- WEB_PORT=13000 st_ui() { [ "$STAND_UP" = 1 ] && [ -f "$OUT/stand/report.json" ] || { printf 'ui\tstand-required\t1\t0\t-\n' >>"$RESULTS" log "ui: стенд не поднят в этом запуске (этап stand) — КРАСНЫЙ" return 1 } if [ -z "$WEB_TMP" ]; then web_copy >/dev/null step ui npm-install web_install "$WEB_TMP" fi step ui browser uv run --with "$PLAYWRIGHT" playwright install chromium (cd "$WEB_TMP" && REACT_APP_ROOT_PATH=/ ARM_CORE_URL="http://127.0.0.1:$ARM_CORE_PORT" \ exec npx rsbuild dev --port $WEB_PORT) >"$OUT/logs/ui.web-server.log" 2>&1 & step ui web-server sh -c "for i in \$(seq 1 60); do curl -sf -o /dev/null http://127.0.0.1:$WEB_PORT/ && exit 0; sleep 2; done; echo 'dev-сервер фронта не поднялся за 120 с'; tail -20 '$OUT/logs/ui.web-server.log'; exit 1" || return 1 step ui smoke uv run --with "$PLAYWRIGHT" python "$HERE/ui_smoke.py" --web "http://127.0.0.1:$WEB_PORT" \ --api "http://127.0.0.1:$ARM_CORE_PORT" --stand-report "$OUT/stand/report.json" --out "$OUT/ui" lsof -ti "tcp:$WEB_PORT" -sTCP:LISTEN | xargs kill 2>/dev/null return 0 } # --- парсер по банку первички (doc-bank) со сравнением с прошлым прогоном -------------------------------------------- st_bank() { [ -d "$WS/doc-bank/files" ] || { log "bank: нет $WS/doc-bank — этап пропущен"; return 0; } local base="${BANK_BASELINE:-$(prev_run bank/results.jsonl)}" step bank parse "$WS/tabular-parser/api/.venv/bin/python" -I "$HERE/parser_bank.py" \ --parser-api "$WS/tabular-parser/api" --bank "$WS/doc-bank" --out "$OUT/bank" ${base:+--baseline "$base"} } # --- итог ------------------------------------------------------------------------------------------------------------ summary() { python3 -I "$HERE/summary.py" "$OUT" "$ALL_STAGES"; } touch "$RESULTS" # Повторный запуск этапов в тот же --out: строки этих этапов из прошлого запуска убираем, остальные остаются в итоге for s in $ALL_STAGES; do want "$s" || continue awk -F'\t' -v s="$s" '$1 != s' "$RESULTS" >"$RESULTS.tmp" && mv "$RESULTS.tmp" "$RESULTS" done log "регрессия: workspace $WS, результат $OUT" rc=0 for s in $ALL_STAGES; do want "$s" || continue "st_$s" src=$? if [ "$s" = preflight ] && [ $src -eq 2 ]; then rc=2; break; fi done stand_teardown summary [ -n "$WEB_TMP" ] && rm -rf "$WEB_TMP" # awk завершается кодом 0, если есть красный шаг if [ $rc -eq 0 ] && awk -F'\t' '$3 != 0 { bad = 1 } END { exit !bad }' "$RESULTS"; then rc=1; fi log "итог: $OUT/summary.md (код $rc)" exit $rc