#!/usr/bin/env bash # 2026-03-22 — operator_survival_test.sh # ТЕСТ НА ВЫЖИВАНИЕ — суровые многоуровневые нагрузочные сценарии оператора. # # Группа 5: FLOOD BUILD — 6 функций параллельно (3 Python + 3 Node.js), все до Ready # Группа 6: RAPID DISCARD STORM — 30 create→DELETE без build, проверка на мусор # Группа 7: CHURN UNDER FIRE — 10 PUT с меняющимися env под 200 параллельными invokes # Группа 8: PHOENIX — 3 полных цикла DELETE+recreate одного имени # Группа 9: SELF-HEALING MARATHON — 5 раз вручную убить Deployment, ждать восстановления # Группа 10: INVOKE HURRICANE — 500 параллельных invokes в 5 волнах, считаем success rate # Группа 11: PG STORM — 300+ параллельных INSERT/COUNT под нагрузкой; таблица test_ # # Ожидаемое время прогона: 90-120 минут. # # ЗАПУСКАТЬ НА VM: # nohup bash ~/terra/sless/operator_survival_test.sh > /tmp/survival.log 2>&1 & # tail -f /tmp/survival.log # # Параметризация через env (для multi-user wrapper): # SLESS_TOKEN — JWT токен (по умолчанию из ~/terra/sless/test.token) # SLESS_NS — namespace (по умолчанию sless-ffd1f598c169b0ae) # PG_HOST / PG_PORT / PG_USER / PG_PASSWORD / PG_DATABASE — для группы 11 # # Зависимости: curl, python3, zip, kubectl set -uo pipefail # ─── CONFIG ────────────────────────────────────────────────────────────────── # Параметризация: дефолты переопределяются через env при multi-user запуске TOKEN="${SLESS_TOKEN:-$(cat /home/naeel/terra/sless/test.token)}" NS="${SLESS_NS:-sless-ffd1f598c169b0ae}" # PG кредентиалы для Group 11 — хардкод для теста, env переопределяет при multi-user PG_HOST="${PG_HOST:-postgresqlk8s-master.bba55e0f-4c51-4312-8ad3-efc557de331d.svc.cluster.local}" PG_PORT="${PG_PORT:-5432}" PG_USER="${PG_USER:-user0}" PG_PASSWORD="${PG_PASSWORD:-iuEHVxxIPfdFr2kWWmSfNJbgCmDzuEC1rQy38aCNj0rYF23BHguhtKFUAioR1Z4F}" PG_DATABASE="${PG_DATABASE:-db0}" # Имя таблицы: test_ — изоляция по namespace; hyphens→underscores для SQL PG_TABLE="test_$(echo "$NS" | tr '-' '_')" API="https://sless.kube5s.ru/v1/namespaces/$NS" FN_BASE="https://sless.kube5s.ru/fn/$NS" DEPLOY_NS="sless-fn-$NS" # Уникальный префикс — не пересекается с prod-сервисами и lifecycle-тестом TS=$(date +%s) SFX="sv-${TS}" # sv = survival # ─── СТАТИСТИКА (глобальная) ────────────────────────────────────────────────── PASS=0; FAIL=0 declare -A GRP_PASS GRP_FAIL # per-group counters # ─── ЦВЕТА ─────────────────────────────────────────────────────────────────── GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m' CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' # ─── HELPERS ───────────────────────────────────────────────────────────────── _cur_grp="" # текущая группа для per-group счётчиков pass() { echo -e " ${GREEN}[PASS]${RESET} $1" PASS=$((PASS + 1)) [[ -n "$_cur_grp" ]] && GRP_PASS[$_cur_grp]=$(( ${GRP_PASS[$_cur_grp]:-0} + 1 )) } fail() { echo -e " ${RED}[FAIL]${RESET} $1" FAIL=$((FAIL + 1)) [[ -n "$_cur_grp" ]] && GRP_FAIL[$_cur_grp]=$(( ${GRP_FAIL[$_cur_grp]:-0} + 1 )) } info() { echo -e " ${CYAN}[INFO]${RESET} $1"; } warn() { echo -e " ${YELLOW}[WARN]${RESET} $1"; } section() { _cur_grp="G$1" echo -e "\n${BOLD}━━━ ГРУППА $1: $2 ━━━${RESET}" GRP_PASS[$_cur_grp]=0; GRP_FAIL[$_cur_grp]=0 } # api_code METHOD URL [body] → HTTP status code api_code() { local method="$1" url="$2" body="${3:-}" local args=(-s -o /dev/null -w "%{http_code}" -m 120 -H "Authorization: Bearer $TOKEN") [[ "$method" != "GET" ]] && args+=(-X "$method") [[ -n "$body" ]] && args+=(-H "Content-Type: application/json" -d "$body") curl "${args[@]}" "$url" } # api_json METHOD URL [body] → response body api_json() { local method="$1" url="$2" body="${3:-}" local args=(-s -m 120 -H "Authorization: Bearer $TOKEN") [[ "$method" != "GET" ]] && args+=(-X "$method") [[ -n "$body" ]] && args+=(-H "Content-Type: application/json" -d "$body") curl "${args[@]}" "$url" } check_code() { local label="$1" got="$2" want="$3" if [[ "$got" == "$want" ]]; then pass "$label → HTTP $got" else fail "$label → HTTP $got (ожидали $want)"; fi } # svc_phase NAME → phase string svc_phase() { api_json GET "$API/services/$1" \ | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('phase',''))" 2>/dev/null } # wait_phase NAME WANT TIMEOUT_SEC → 0=ok 1=timeout 2=Failed wait_phase() { local name="$1" want="$2" timeout_sec="${3:-300}" local deadline=$((SECONDS + timeout_sec)) while [[ $SECONDS -lt $deadline ]]; do local phase phase=$(svc_phase "$name") [[ "$phase" == "$want" ]] && return 0 [[ "$phase" == "Failed" && "$want" != "Failed" ]] && return 2 sleep 5 done return 1 } # wait_all_ready TIMEOUT NAME1 NAME2 ... → число сколько НЕ достигло Ready wait_all_ready() { local timeout_sec="$1"; shift local names=("$@") local deadline=$((SECONDS + timeout_sec)) local not_ready=( "${names[@]}" ) while [[ $SECONDS -lt $deadline && ${#not_ready[@]} -gt 0 ]]; do local still=() for n in "${not_ready[@]}"; do local p p=$(svc_phase "$n") [[ "$p" == "Ready" ]] || still+=("$n") done not_ready=( "${still[@]}" ) [[ ${#not_ready[@]} -eq 0 ]] && break sleep 5 done echo "${#not_ready[@]}" # количество не дошедших до Ready } # make_python_zip NAME → /tmp/surv_py_{NAME}.zip # Python echo-handler: возвращает {"ok":true,"env":TEST_VAR,"grp":GRP_VAR} make_python_zip() { local name="$1" local tmpdir; tmpdir=$(mktemp -d) cat > "$tmpdir/handler.py" <<'PYEOF' import os, json def handle(event): return { "ok": True, "env": os.environ.get("TEST_VAR", ""), "grp": os.environ.get("GRP_VAR", ""), "msg": event.get("msg", ""), "iter": event.get("iter", 0), } PYEOF (cd "$tmpdir" && zip -q "/tmp/surv_py_${name}.zip" handler.py) rm -rf "$tmpdir" } # make_node_zip NAME → /tmp/surv_node_{NAME}.zip make_node_zip() { local name="$1" local tmpdir; tmpdir=$(mktemp -d) cat > "$tmpdir/handler.js" <<'JSEOF' 'use strict'; exports.handle = function(event) { return { ok: true, env: process.env.TEST_VAR || '', grp: process.env.GRP_VAR || '', msg: (event && event.msg) || '', iter: (event && event.iter) || 0, }; }; JSEOF (cd "$tmpdir" && zip -q "/tmp/surv_node_${name}.zip" handler.js) rm -rf "$tmpdir" } # upload_zip NAME ZIPFILE → HTTP code upload_zip() { local name="$1" zipfile="$2" curl -s -o /dev/null -w "%{http_code}" -m 120 \ -H "Authorization: Bearer $TOKEN" \ -F "code=@$zipfile" \ "$API/services/$name/upload" } # create_py_deploy NAME MEMORY ENV_VARS_JSON — create + upload python handler # return: 0=ok, 1=fail create_py_deploy() { local name="$1" mem="${2:-128}" envj="${3:-{}}" local http http=$(api_code POST "$API/services" \ "{\"name\":\"$name\",\"display_name\":\"survival test\",\ \"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\ \"memory_mb\":$mem,\"env_vars\":$envj}") [[ "$http" == "201" ]] || { fail "create $name → HTTP $http"; return 1; } make_python_zip "$name" local ucode ucode=$(upload_zip "$name" "/tmp/surv_py_${name}.zip") [[ "$ucode" == "200" ]] || { fail "upload $name → HTTP $ucode"; return 1; } return 0 } # create_node_deploy NAME MEMORY ENV_VARS_JSON — create + upload node handler create_node_deploy() { local name="$1" mem="${2:-128}" envj="${3:-{}}" local http http=$(api_code POST "$API/services" \ "{\"name\":\"$name\",\"display_name\":\"survival test\",\ \"runtime\":\"nodejs20\",\"entrypoint\":\"handler.handle\",\ \"memory_mb\":$mem,\"env_vars\":$envj}") [[ "$http" == "201" ]] || { fail "create $name → HTTP $http"; return 1; } make_node_zip "$name" local ucode ucode=$(upload_zip "$name" "/tmp/surv_node_${name}.zip") [[ "$ucode" == "200" ]] || { fail "upload $name → HTTP $ucode"; return 1; } return 0 } # invoke_one NAME → HTTP code invoke_one() { local name="$1" curl -s -o /dev/null -w "%{http_code}" -m 30 \ -X POST -H "Content-Type: application/json" \ -d '{"msg":"survival"}' \ "$FN_BASE/$name" } # invoke_burst NAME COUNT PARALLEL — запустить COUNT запросов пачками PARALLEL # выводит "SUCCESS=N FAIL=M" invoke_burst() { local name="$1" total="$2" parallel="$3" local results_dir results_dir=$(mktemp -d) local idx=0 while [[ $idx -lt $total ]]; do local batch_pids=() local batch=0 while [[ $batch -lt $parallel && $idx -lt $total ]]; do ( local code code=$(invoke_one "$name") echo "$code" > "$results_dir/${idx}" ) & batch_pids+=($!) idx=$((idx + 1)); batch=$((batch + 1)) done wait "${batch_pids[@]}" done local s=0 f=0 for file in "$results_dir"/*; do local c; c=$(cat "$file") if [[ "$c" == "200" ]]; then s=$((s + 1)); else f=$((f + 1)); fi done rm -rf "$results_dir" echo "SUCCESS=$s FAIL=$f" } # make_python_pg_zip NAME → /tmp/surv_py_pg_{NAME}.zip # Python PostgreSQL handler: поддерживает action=init/insert/count. # Имя таблицы берётся из env PG_TABLE, передаваемого при деплое. make_python_pg_zip() { local name="$1" local tmpdir; tmpdir=$(mktemp -d) cat > "$tmpdir/handler.py" <<'PYEOF' import os, json, psycopg2 # Имя таблицы задаётся через env PG_TABLE при деплое — гарантирует изоляцию namespace TABLE = os.environ.get("PG_TABLE", "test_default") def _connect(): return psycopg2.connect( host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", "5432")), dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"], password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"), ) def handle(event): action = event.get("action", "insert") val = str(event.get("val", "survival")) conn = _connect() try: with conn: with conn.cursor() as cur: if action == "init": cur.execute( "CREATE TABLE IF NOT EXISTS {} " "(id SERIAL PRIMARY KEY, val TEXT, ts TIMESTAMPTZ DEFAULT NOW())".format(TABLE) ) cur.execute("SELECT COUNT(*) FROM {}".format(TABLE)) return {"ok": True, "action": "init", "count": cur.fetchone()[0]} elif action == "insert": cur.execute("INSERT INTO {} (val) VALUES (%s)".format(TABLE), (val,)) return {"ok": True, "action": "insert"} elif action == "count": cur.execute("SELECT COUNT(*) FROM {}".format(TABLE)) return {"ok": True, "action": "count", "count": cur.fetchone()[0]} else: return {"ok": False, "error": "unknown action: " + action} finally: conn.close() PYEOF # psycopg2-binary устанавливается через requirements.txt при сборке Docker-образа функции printf 'psycopg2-binary==2.9.9\n' > "$tmpdir/requirements.txt" (cd "$tmpdir" && zip -q "/tmp/surv_py_pg_${name}.zip" handler.py requirements.txt) rm -rf "$tmpdir" } # create_pg_deploy NAME TABLE — создать + загрузить PG функцию с нужными env vars # PG переменные берутся из глобального CONFIG скрипта (или env override при multi-user) create_pg_deploy() { local name="$1" table="$2" local envj envj=$(printf \ '{"PGHOST":"%s","PGPORT":"%s","PGDATABASE":"%s","PGUSER":"%s","PGPASSWORD":"%s","PGSSLMODE":"require","PG_TABLE":"%s"}' \ "$PG_HOST" "$PG_PORT" "$PG_DATABASE" "$PG_USER" "$PG_PASSWORD" "$table") local http http=$(api_code POST "$API/services" \ "{\"name\":\"$name\",\"display_name\":\"pg storm test\",\ \"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\ \"memory_mb\":256,\"env_vars\":$envj}") [[ "$http" == "201" ]] || { fail "create PG $name → HTTP $http"; return 1; } make_python_pg_zip "$name" local ucode ucode=$(upload_zip "$name" "/tmp/surv_py_pg_${name}.zip") [[ "$ucode" == "200" ]] || { fail "upload PG $name → HTTP $ucode"; return 1; } return 0 } # pg_invoke_burst NAME COUNT PARALLEL ACTION — параллельные invokes с заданным PG action # Выводит "SUCCESS=N FAIL=M" pg_invoke_burst() { local name="$1" total="$2" parallel="$3" action="${4:-insert}" local tmp; tmp=$(mktemp -d) local idx=0 while [[ $idx -lt $total ]]; do local pids=() batch=0 while [[ $batch -lt $parallel && $idx -lt $total ]]; do ( code=$(curl -s -o /dev/null -w "%{http_code}" -m 30 \ -X POST -H "Content-Type: application/json" \ -d "{\"action\":\"${action}\",\"val\":\"burst-${idx}\"}" \ "$FN_BASE/$name") echo "$code" > "$tmp/${idx}" ) & pids+=($!); idx=$((idx+1)); batch=$((batch+1)) done wait "${pids[@]}" done local s=0 f=0 for file in "$tmp"/*; do c=$(cat "$file") [[ "$c" == "200" ]] && s=$((s+1)) || f=$((f+1)) done rm -rf "$tmp" echo "SUCCESS=$s FAIL=$f" } # pg_count_fn NAME → число строк таблицы (из ответа PG функции) pg_count_fn() { local name="$1" curl -s -m 30 -X POST -H "Content-Type: application/json" \ -d '{"action":"count"}' "$FN_BASE/$name" \ | python3 -c 'import sys,json; print(json.load(sys.stdin).get("count",0))' 2>/dev/null \ || echo "0" } # pg_init_fn NAME → "True"/"False" по полю ok ответа pg_init_fn() { local name="$1" curl -s -m 60 -X POST -H "Content-Type: application/json" \ -d '{"action":"init"}' "$FN_BASE/$name" \ | python3 -c 'import sys,json; print(json.load(sys.stdin).get("ok",False))' 2>/dev/null \ || echo "False" } # deploy_ns_has NAME → 0 если Deployment существует deploy_ns_has() { kubectl get deployment -n "$DEPLOY_NS" "$1" --no-headers 2>/dev/null | grep -q "$1" } # cleanup NAME — удалить сервис (teardown) cleanup() { local name="$1" [[ -z "$name" ]] && return 0 # пустое имя — пропускаем api_code DELETE "$API/services/$name" > /dev/null 2>&1 || true sleep 2 } # Список для teardown — заполняется по ходу тестов CLEANUP_NAMES=() teardown() { if [[ ${#CLEANUP_NAMES[@]} -gt 0 ]]; then echo -e "\n${CYAN}[TEARDOWN]${RESET} удаляю тестовые сервисы..." for name in "${CLEANUP_NAMES[@]}"; do [[ -z "$name" ]] && continue # пропускаем пустые элементы cleanup "$name" echo -e " ${CYAN}[TEARDOWN]${RESET} удалён: $name" done fi } trap teardown EXIT # ─── СТАРТОВЫЙ БАННЕР ──────────────────────────────────────────────────────── echo "" echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${RESET}" echo -e "${BOLD}║ OPERATOR SURVIVAL TEST — sless v0.1.49 ║${RESET}" echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${RESET}" echo -e " Дата : $(date '+%Y-%m-%d %H:%M:%S')" echo -e " API : $API" echo -e " SFX : $SFX" echo "" # ═══════════════════════════════════════════════════════════════════════════════ section 5 "FLOOD BUILD — 6 функций параллельно (3 × Python + 3 × Node.js)" # ═══════════════════════════════════════════════════════════════════════════════ # Цель: убедиться что много одновременных builds не мешают друг другу, # все достигают Ready, все отвечают на invoke. FB1="fb1-${SFX}"; FB2="fb2-${SFX}"; FB3="fb3-${SFX}" FB4="fb4-${SFX}"; FB5="fb5-${SFX}"; FB6="fb6-${SFX}" FB_ALL=("$FB1" "$FB2" "$FB3" "$FB4" "$FB5" "$FB6") CLEANUP_NAMES+=( "${FB_ALL[@]}" ) info "5.1 CREATE + UPLOAD 6 функций параллельно (3 Python / 3 Node.js)..." (set +e; create_py_deploy "$FB1" 128 '{"GRP_VAR":"py1"}') & (set +e; create_py_deploy "$FB2" 192 '{"GRP_VAR":"py2"}') & (set +e; create_py_deploy "$FB3" 256 '{"GRP_VAR":"py3"}') & (set +e; create_node_deploy "$FB4" 128 '{"GRP_VAR":"nd1"}') & (set +e; create_node_deploy "$FB5" 192 '{"GRP_VAR":"nd2"}') & (set +e; create_node_deploy "$FB6" 256 '{"GRP_VAR":"nd3"}') & wait info "5.2 Проверяем что все 6 CRD созданы (GET → 200)..." # Небольшая пауза — контроллер устанавливает phase асинхронно после create sleep 5 all_created=true for name in "${FB_ALL[@]}"; do http_check=$(api_code GET "$API/services/$name") if [[ "$http_check" != "200" ]]; then fail "CRD $name не существует → HTTP $http_check (create/upload провалился)" all_created=false fi done $all_created && pass "все 6 CRD созданы" info "5.3 Ждём ALL READY (450s для 6 параллельных builds)..." not_ready_count=$(wait_all_ready 450 "${FB_ALL[@]}") if [[ "$not_ready_count" -eq 0 ]]; then pass "все 6 функций достигли Ready ✓" else fail "$not_ready_count из 6 НЕ достигли Ready за 450s" fi # Ждём rollout всех Deployment info "5.4 Ждём rollout всех 6 Deployment (120s)..." all_rollout=true for name in "${FB_ALL[@]}"; do if ! kubectl rollout status deployment/"$name" -n "$DEPLOY_NS" --timeout=120s \ > /dev/null 2>&1; then warn "$name rollout не завершился" all_rollout=false fi done sleep 5 $all_rollout && pass "все 6 Deployment rollout завершён" info "5.5 Python-функции: invoke × 3 к каждой, проверяем ok=True..." py_ok=true for name in "$FB1" "$FB2" "$FB3"; do for _i in 1 2 3; do rv=$(curl -s -m 20 -X POST -H "Content-Type: application/json" \ -d '{"msg":"flood"}' "$FN_BASE/$name") ok_val=$(echo "$rv" | python3 -c \ "import sys,json; d=json.load(sys.stdin); print(d.get('ok',''))" 2>/dev/null) if [[ "$ok_val" != "True" ]]; then fail "$name invoke[$_i] → ok='$ok_val' (resp: ${rv:0:100})" py_ok=false; break fi done done $py_ok && pass "все Python-функции invoke → ok=True" info "5.6 Node.js-функции: invoke × 3 к каждой, проверяем ok=true..." nd_ok=true for name in "$FB4" "$FB5" "$FB6"; do for _i in 1 2 3; do rv=$(curl -s -m 20 -X POST -H "Content-Type: application/json" \ -d '{"msg":"flood"}' "$FN_BASE/$name") ok_val=$(echo "$rv" | python3 -c \ "import sys,json; d=json.load(sys.stdin); print(str(d.get('ok','')).lower())" 2>/dev/null) if [[ "$ok_val" != "true" ]]; then fail "$name invoke[$_i] → ok='$ok_val' (resp: ${rv:0:100})" nd_ok=false; break fi done done $nd_ok && pass "все Node.js-функции invoke → ok=true" info "5.7 GRP_VAR env vars различаются у каждой функции..." env_ok=true declare -A EXPECTED_GRP EXPECTED_GRP["$FB1"]="py1"; EXPECTED_GRP["$FB2"]="py2"; EXPECTED_GRP["$FB3"]="py3" EXPECTED_GRP["$FB4"]="nd1"; EXPECTED_GRP["$FB5"]="nd2"; EXPECTED_GRP["$FB6"]="nd3" for name in "${FB_ALL[@]}"; do rv=$(curl -s -m 20 -X POST -H "Content-Type: application/json" \ -d '{}' "$FN_BASE/$name") got_grp=$(echo "$rv" | python3 -c \ "import sys,json; d=json.load(sys.stdin); print(d.get('grp',''))" 2>/dev/null) want="${EXPECTED_GRP[$name]}" if [[ "$got_grp" != "$want" ]]; then fail "$name grp='$got_grp' (ожидали '$want')" env_ok=false fi done $env_ok && pass "GRP_VAR корректен у всех 6 функций" info "5.8 memory_mb применён в k8s Deployments..." mem_ok=true declare -A EXPECTED_MEM EXPECTED_MEM["$FB1"]="128Mi"; EXPECTED_MEM["$FB2"]="192Mi"; EXPECTED_MEM["$FB3"]="256Mi" EXPECTED_MEM["$FB4"]="128Mi"; EXPECTED_MEM["$FB5"]="192Mi"; EXPECTED_MEM["$FB6"]="256Mi" for name in "${FB_ALL[@]}"; do got_mem=$(kubectl get deployment "$name" -n "$DEPLOY_NS" \ -o jsonpath='{.spec.template.spec.containers[0].resources.limits.memory}' 2>/dev/null) want="${EXPECTED_MEM[$name]}" if [[ "$got_mem" != "$want" ]]; then fail "$name k8s memory='$got_mem' (ожидали '$want')" mem_ok=false fi done $mem_ok && pass "k8s memory limits корректны у всех 6 функций" info "5.9 Удаляем все 6 flood-функций параллельно..." del_ok=true for name in "${FB_ALL[@]}"; do (api_code DELETE "$API/services/$name" > /dev/null 2>&1) & done wait sleep 5 for name in "${FB_ALL[@]}"; do code=$(api_code GET "$API/services/$name") [[ "$code" == "404" ]] || { fail "$name: GET после DELETE → $code (ожидали 404)"; del_ok=false; } done $del_ok && pass "все 6 функций удалены, GET → 404" # Убираем из cleanup (уже удалены) — фильтруем пустые и fb-имена new_cleanup=() for e in "${CLEANUP_NAMES[@]}"; do skip=false for fn in "${FB_ALL[@]}"; do [[ "$e" == "$fn" ]] && skip=true; done [[ -z "$e" ]] && skip=true $skip || new_cleanup+=("$e") done CLEANUP_NAMES=( "${new_cleanup[@]}" ) # Ждём очистки k8s sleep 15 # ═══════════════════════════════════════════════════════════════════════════════ section 6 "RAPID DISCARD STORM — 30 create→DELETE без build" # ═══════════════════════════════════════════════════════════════════════════════ # Цель: убедиться что 30 быстрых create+delete не оставляют orphan CRD. # Кейс: пользователь создал функцию, сразу передумал и удалил без upload. info "6.1 Создаём + сразу удаляем 30 функций (10 параллельно, 3 волны)..." rd_creates_ok=0 rd_deletes_ok=0 rd_total=30 results_dir_6=$(mktemp -d) overflow=0 for wave in 1 2 3; do wave_pids=() for slot in $(seq 1 10); do idx=$(( (wave - 1) * 10 + slot )) ( set +e rname="rd${idx}-${SFX}" # CREATE http_c=$(api_code POST "$API/services" \ "{\"name\":\"$rname\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"h.h\",\"memory_mb\":128,\"env_vars\":{}}") # Немедленно DELETE — не ждём Building http_d=$(api_code DELETE "$API/services/$rname") echo "${http_c}:${http_d}" > "$results_dir_6/${idx}" ) & wave_pids+=($!) done wait "${wave_pids[@]}" done # Подсчёт результатов for file in "$results_dir_6"/*; do pair=$(cat "$file") c_code="${pair%%:*}" d_code="${pair##*:}" [[ "$c_code" == "201" ]] && rd_creates_ok=$((rd_creates_ok + 1)) [[ "$d_code" == "204" || "$d_code" == "404" ]] && rd_deletes_ok=$((rd_deletes_ok + 1)) done rm -rf "$results_dir_6" if [[ $rd_creates_ok -ge 27 ]]; then # допустим ≤3 ошибки (гонка) pass "$rd_creates_ok/$rd_total create вернули 201" else fail "$rd_creates_ok/$rd_total create вернули 201 (ожидали ≥27)" fi if [[ $rd_deletes_ok -ge 27 ]]; then pass "$rd_deletes_ok/$rd_total delete вернули 204/404" else fail "$rd_deletes_ok/$rd_total delete ок (ожидали ≥27)" fi info "6.2 Ждём 20s чтобы finalizer-ы отработали..." sleep 20 info "6.3 Проверяем что нет orphan CRD в namespace $NS..." orphan_count=$(api_json GET "$API/services" \ | python3 -c " import sys, json data = json.load(sys.stdin) items = data if isinstance(data, list) else data.get('items', data.get('services', [])) surv = [i.get('name','') for i in items if '${SFX}' in i.get('name','')] print(len(surv)) print('\n'.join(surv)) " 2>/dev/null | head -1) if [[ "$orphan_count" -eq 0 ]]; then pass "нет orphan CRD — все $rd_total функций удалены чисто" else fail "$orphan_count orphan CRD обнаружено после rapid discard" fi info "6.4 Нет orphan Deployment/Service в deploy namespace $DEPLOY_NS..." orphan_k8s=$(kubectl get deployment -n "$DEPLOY_NS" --no-headers 2>/dev/null \ | grep "${SFX}" | wc -l) if [[ "$orphan_k8s" -eq 0 ]]; then pass "нет orphan Deployment в $DEPLOY_NS" else fail "$orphan_k8s orphan Deployment в $DEPLOY_NS после rapid discard" fi # ═══════════════════════════════════════════════════════════════════════════════ section 7 "CHURN UNDER FIRE — 10 PUT env-updates под 200 параллельными invokes" # ═══════════════════════════════════════════════════════════════════════════════ # Цель: обновления конфигурации не ломают работу функции под нагрузкой. # Метрика: success rate invoke >= 90%, финальный env корректный. CU="churn-${SFX}" CLEANUP_NAMES+=("$CU") info "7.1 Создаём и деплоим churn-функцию..." if create_py_deploy "$CU" 192 '{"TEST_VAR":"ver-0","GRP_VAR":"churn"}'; then pass "create+upload $CU OK" else fail "create+upload $CU провалился, пропускаем группу 7" # Очистка + переход к следующей группе cleanup "$CU" fi info "7.2 Ждём Ready (300s)..." cu_ready=0 wait_phase "$CU" "Ready" 300 || cu_ready=$? if [[ $cu_ready -eq 0 ]]; then pass "$CU Ready" kubectl rollout status deployment/"$CU" -n "$DEPLOY_NS" --timeout=60s \ > /dev/null 2>&1 || true sleep 3 else fail "$CU не вышел в Ready (фаза: $(svc_phase "$CU")), пропускаем тест" cleanup "$CU"; CLEANUP_NAMES=( "${CLEANUP_NAMES[@]/$CU}" ) # jump over group 7 остальные тесты пропустим cu_ready=99 fi if [[ $cu_ready -eq 0 ]]; then info "7.3 Запускаем фоновый поток: 200 invoke (10 параллельно × 20 волн)..." # Invoke в фоне — пишем коды в файлы churn_results=$(mktemp -d) ( for wave in $(seq 1 20); do wave_pids=() for _b in $(seq 1 10); do idx=$(( (wave - 1) * 10 + _b )) ( code=$(invoke_one "$CU") echo "$code" > "$churn_results/${idx}" ) & wave_pids+=($!) done wait "${wave_pids[@]}" sleep 0.5 # небольшая пауза между волнами чтобы не throttle done ) & fire_pid=$! info "7.4 Пока идут invoke — делаем 10 PUT с нарастающим TEST_VAR..." for ver in $(seq 1 10); do sleep 4 # ~каждые 4s новый PUT http_put=$(api_code PUT "$API/services/$CU" \ "{\"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\ \"memory_mb\":192,\"env_vars\":{\"TEST_VAR\":\"ver-${ver}\",\"GRP_VAR\":\"churn\"}}") if [[ "$http_put" == "200" ]]; then info " PUT ver-${ver} → 200 ✓" else fail "PUT ver-${ver} → HTTP $http_put" fi done info "7.5 Ждём завершения фонового invoke-потока..." wait $fire_pid # Подсчёт invoke результатов cu_success=0; cu_fail_cnt=0 for file in "$churn_results"/*; do code=$(cat "$file") if [[ "$code" == "200" ]]; then cu_success=$((cu_success + 1)) else cu_fail_cnt=$((cu_fail_cnt + 1)); fi done rm -rf "$churn_results" total_invokes=$((cu_success + cu_fail_cnt)) success_pct=0 [[ $total_invokes -gt 0 ]] && success_pct=$(( cu_success * 100 / total_invokes )) info " Invoke сводка: $cu_success/$total_invokes успешных ($success_pct%)" if [[ $success_pct -ge 90 ]]; then pass "invoke success rate $success_pct% ≥ 90% под 10 rolling updates" else fail "invoke success rate $success_pct% < 90% (много гонок при update?)" fi info "7.6 Ждём rollout финального обновления (ver-10)..." sleep 10 kubectl rollout status deployment/"$CU" -n "$DEPLOY_NS" --timeout=90s \ > /dev/null 2>&1 || true sleep 5 info "7.7 Финальный invoke → TEST_VAR должен быть ver-10..." final_resp=$(curl -s -m 20 -X POST -H "Content-Type: application/json" \ -d '{}' "$FN_BASE/$CU") final_env=$(echo "$final_resp" | python3 -c \ "import sys,json; d=json.load(sys.stdin); print(d.get('env',''))" 2>/dev/null) if [[ "$final_env" == "ver-10" ]]; then pass "финальный env=ver-10 ✓" else fail "финальный env='$final_env' (ожидали 'ver-10')" fi info "7.8 k8s Deployment env = ver-10..." k8s_env_cu=$(kubectl get deployment "$CU" -n "$DEPLOY_NS" -o json 2>/dev/null \ | python3 -c "import sys,json d=json.load(sys.stdin) envs=d['spec']['template']['spec']['containers'][0].get('env',[]) val=next((e['value'] for e in envs if e['name']=='TEST_VAR'), '') print(val)" 2>/dev/null) if [[ "$k8s_env_cu" == "ver-10" ]]; then pass "k8s Deployment TEST_VAR=ver-10 ✓" else fail "k8s Deployment TEST_VAR='$k8s_env_cu' (ожидали 'ver-10')" fi fi info "7.9 Удаляем churn-функцию..." check_code "DELETE $CU" "$(api_code DELETE "$API/services/$CU")" "204" CLEANUP_NAMES=( "${CLEANUP_NAMES[@]/$CU}" ) # ═══════════════════════════════════════════════════════════════════════════════ section 8 "PHOENIX — 3 цикла DELETE + recreate одного имени" # ═══════════════════════════════════════════════════════════════════════════════ # Цель: после DELETE сервиса его имя можно переиспользовать немедленно. # Финалайзер должен корректно удалить k8s ресурсы прежде чем CRD исчезнет. # Каждый цикл: create → wait Ready → DELETE → wait 404 → снова create. PX="phoenix-${SFX}" CLEANUP_NAMES+=("$PX") phoenix_all_ok=true for cycle in 1 2 3; do info "8.${cycle} PHOENIX цикл $cycle/3..." # CREATE cx_code=$(api_code POST "$API/services" \ "{\"name\":\"$PX\",\"display_name\":\"phoenix c$cycle\",\ \"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\ \"memory_mb\":128,\"env_vars\":{\"TEST_VAR\":\"cycle-${cycle}\"}}") if [[ "$cx_code" != "201" ]]; then fail " phoenix цикл $cycle: CREATE → HTTP $cx_code (ожидали 201)" phoenix_all_ok=false; continue fi # UPLOAD make_python_zip "${PX}_cycle${cycle}" cp "/tmp/surv_py_${PX}_cycle${cycle}.zip" "/tmp/surv_py_${PX}.zip" 2>/dev/null || true make_python_zip "${PX}" u_code=$(upload_zip "$PX" "/tmp/surv_py_${PX}.zip") if [[ "$u_code" != "200" ]]; then fail " phoenix цикл $cycle: UPLOAD → HTTP $u_code" phoenix_all_ok=false; cleanup "$PX"; continue fi # WAIT READY px_w=0 wait_phase "$PX" "Ready" 300 || px_w=$? if [[ $px_w -ne 0 ]]; then fail " phoenix цикл $cycle: не дошёл до Ready (w=$px_w, phase=$(svc_phase "$PX"))" phoenix_all_ok=false; cleanup "$PX"; continue fi kubectl rollout status deployment/"$PX" -n "$DEPLOY_NS" --timeout=60s \ > /dev/null 2>&1 || true sleep 3 # INVOKE — убедиться что работает и env правильный px_resp=$(curl -s -m 20 -X POST -H "Content-Type: application/json" \ -d '{}' "$FN_BASE/$PX") px_env=$(echo "$px_resp" | python3 -c \ "import sys,json; d=json.load(sys.stdin); print(d.get('env',''))" 2>/dev/null) if [[ "$px_env" == "cycle-${cycle}" ]]; then pass " phoenix цикл $cycle: Ready + invoke env=cycle-${cycle} ✓" else fail " phoenix цикл $cycle: invoke env='$px_env' (ожидали 'cycle-${cycle}')" phoenix_all_ok=false fi # DELETE del_code=$(api_code DELETE "$API/services/$PX") if [[ "$del_code" != "204" ]]; then fail " phoenix цикл $cycle: DELETE → HTTP $del_code" phoenix_all_ok=false; continue fi # WAIT 404 — ждём когда finalizer уберёт CRD info " Ждём когда CRD исчезнет (finalizer, 60s)..." gone=false for _w in $(seq 1 12); do sleep 5 code404=$(api_code GET "$API/services/$PX") if [[ "$code404" == "404" ]]; then gone=true; break; fi done if ! $gone; then fail " phoenix цикл $cycle: CRD $PX не исчез за 60s после DELETE" phoenix_all_ok=false fi # Проверяем k8s Deployment тоже удалён k8s_gone=false for _w in $(seq 1 6); do sleep 5 deploy_ns_has "$PX" || { k8s_gone=true; break; } done if ! $k8s_gone; then fail " phoenix цикл $cycle: k8s Deployment не удалён за 30s" phoenix_all_ok=false fi info " Цикл $cycle завершён — имя $PX освободилось" done if $phoenix_all_ok; then pass "PHOENIX: все 3 цикла DELETE+recreate прошли успешно" fi CLEANUP_NAMES=( "${CLEANUP_NAMES[@]/$PX}" ) # ═══════════════════════════════════════════════════════════════════════════════ section 9 "SELF-HEALING MARATHON — 5 раз вручную убить Deployment" # ═══════════════════════════════════════════════════════════════════════════════ # Цель: оператор с RequeueAfter:60s восстанавливает Deployment каждый раз. # Без сбоев подряд: удаляем → ждём 90s → убедиться что пересоздан → invoke OK. SH="sheal-${SFX}" CLEANUP_NAMES+=("$SH") info "9.1 Создаём и деплоим self-healing функцию..." if create_py_deploy "$SH" 128 '{"TEST_VAR":"sheal"}'; then sh_ready=0 wait_phase "$SH" "Ready" 300 || sh_ready=$? if [[ $sh_ready -eq 0 ]]; then pass "$SH Ready перед self-healing marathon" kubectl rollout status deployment/"$SH" -n "$DEPLOY_NS" --timeout=60s \ > /dev/null 2>&1 || true sleep 3 else fail "$SH не вышел в Ready, пропускаем группу 9" sh_ready=99 fi else fail "create/upload $SH провалился, пропускаем группу 9" sh_ready=99 fi if [[ "${sh_ready:-0}" -eq 0 ]]; then sheal_ok=0 for kill_round in 1 2 3 4 5; do info "9.kill$kill_round — убиваем Deployment (раунд $kill_round/5)..." kubectl delete deployment "$SH" -n "$DEPLOY_NS" > /dev/null 2>&1 info " Ждём пересоздания (90s = минимум один RequeueAfter: 60s цикл + delta)..." recreated=false for _w in $(seq 1 18); do sleep 5 if deploy_ns_has "$SH"; then recreated=true; break; fi done if $recreated; then # Ждём pod готовности kubectl rollout status deployment/"$SH" -n "$DEPLOY_NS" --timeout=90s \ > /dev/null 2>&1 || true sleep 3 # Invoke должен работать sh_resp=$(curl -s -m 30 -X POST -H "Content-Type: application/json" \ -d '{"iter":'"$kill_round"'}' "$FN_BASE/$SH") sh_ok=$(echo "$sh_resp" | python3 -c \ "import sys,json; d=json.load(sys.stdin); print(d.get('ok',''))" 2>/dev/null) if [[ "$sh_ok" == "True" ]]; then pass " раунд $kill_round: Deployment пересоздан + invoke OK" sheal_ok=$((sheal_ok + 1)) else fail " раунд $kill_round: Deployment пересоздан, но invoke → ${sh_resp:0:80}" fi else fail " раунд $kill_round: Deployment НЕ пересоздан за 90s после удаления" fi done info "Self-healing итог: $sheal_ok/5 раундов прошли" fi info "9.final Удаляем $SH..." check_code "DELETE $SH" "$(api_code DELETE "$API/services/$SH")" "204" CLEANUP_NAMES=( "${CLEANUP_NAMES[@]/$SH}" ) # ═══════════════════════════════════════════════════════════════════════════════ section 10 "INVOKE HURRICANE — 500 параллельных invokes в 5 волнах" # ═══════════════════════════════════════════════════════════════════════════════ # Цель: функция держит нагрузку 100 одновременных запросов × 5 волн = 500 total. # Метрика: success rate >= 90%, нет зависаний. HU="hurr-${SFX}" CLEANUP_NAMES+=("$HU") info "10.1 Создаём hurricane-функцию (Node.js, 256Mi)..." if create_node_deploy "$HU" 256 '{"TEST_VAR":"hurricane","GRP_VAR":"load"}'; then hu_ready=0 wait_phase "$HU" "Ready" 300 || hu_ready=$? if [[ $hu_ready -eq 0 ]]; then pass "$HU Ready" kubectl rollout status deployment/"$HU" -n "$DEPLOY_NS" --timeout=90s \ > /dev/null 2>&1 || true sleep 5 else fail "$HU не вышел в Ready, пропускаем группу 10" hu_ready=99 fi else fail "create/upload $HU провалился, пропускаем группу 10" hu_ready=99 fi if [[ "${hu_ready:-0}" -eq 0 ]]; then info "10.2 Прогрев: 10 последовательных invoke перед нагрузкой..." warmup_ok=0 for _w in $(seq 1 10); do code=$(invoke_one "$HU") [[ "$code" == "200" ]] && warmup_ok=$((warmup_ok + 1)) done if [[ $warmup_ok -ge 8 ]]; then pass "прогрев: $warmup_ok/10 OK" else fail "прогрев: $warmup_ok/10 OK (функция не стабильна)" fi info "10.3 ВОЛНА 1-5: 100 параллельных запросов в каждой волне (500 total)..." total_ok=0; total_fail=0 for wave in 1 2 3 4 5; do info " ВОЛНА $wave/5 (100 параллельных)..." # Запускаем 100 параллельно wave_dir=$(mktemp -d) wave_pids=() for req in $(seq 1 100); do ( code=$(invoke_one "$HU") echo "$code" > "$wave_dir/${req}" ) & wave_pids+=($!) done wait "${wave_pids[@]}" wave_ok=0; wave_fail=0 for file in "$wave_dir"/*; do c=$(cat "$file") if [[ "$c" == "200" ]]; then wave_ok=$((wave_ok + 1)) else wave_fail=$((wave_fail + 1)); fi done rm -rf "$wave_dir" total_ok=$((total_ok + wave_ok)) total_fail=$((total_fail + wave_fail)) info " волна $wave: $wave_ok/100 OK, $wave_fail FAIL" sleep 2 # небольшой перерыв между волнами done total=$((total_ok + total_fail)) pct=0 [[ $total -gt 0 ]] && pct=$(( total_ok * 100 / total )) info " Итого: $total_ok/$total OK ($pct%)" if [[ $pct -ge 90 ]]; then pass "HURRICANE: $total_ok/$total invoke → ${pct}% success rate (≥90%) ✓" else fail "HURRICANE: $total_ok/$total invoke → ${pct}% success rate (<90%)" fi info "10.4 Функция стабильна после шторма — финальный invoke..." sleep 5 final_code=$(invoke_one "$HU") if [[ "$final_code" == "200" ]]; then pass "финальный invoke после нагрузки → 200 ✓" else fail "финальный invoke после нагрузки → $final_code" fi fi info "10.5 Удаляем hurricane-функцию..." check_code "DELETE $HU" "$(api_code DELETE "$API/services/$HU")" "204" CLEANUP_NAMES=( "${CLEANUP_NAMES[@]/$HU}" ) # ═══════════════════════════════════════════════════════════════════════════════ section 11 "PG STORM — 300+ параллельных INSERT/COUNT; таблица $PG_TABLE" # ═══════════════════════════════════════════════════════════════════════════════ # Цель: проверить что Python-функция с psycopg2 выдерживает конкурентные # INSERT/SELECT в свою изолированную таблицу test_. # Данные не должны теряться под нагрузкой; счётчик строк должен расти корректно. PG_FN="pgstorm-${SFX}" CLEANUP_NAMES+=("$PG_FN") info "11.1 Создаём + загружаем PG функцию $PG_FN (psycopg2, таблица $PG_TABLE)..." if create_pg_deploy "$PG_FN" "$PG_TABLE"; then pass "create+upload $PG_FN → OK" info "11.2 Ждём Ready $PG_FN (300s — pip install psycopg2 в image build)..." if wait_phase "$PG_FN" "Ready" 300; then pass "$PG_FN достиг Ready ✓" else fail "$PG_FN НЕ достиг Ready за 300s" fi kubectl rollout status deployment/"$PG_FN" -n "$DEPLOY_NS" --timeout=60s \ > /dev/null 2>&1 || true sleep 3 info "11.3 init → CREATE TABLE IF NOT EXISTS $PG_TABLE..." pg_init_result=$(pg_init_fn "$PG_FN") if [[ "$pg_init_result" == "True" ]]; then pass "init → таблица $PG_TABLE создана / уже существует ✓" else fail "init → неожиданный ответ: $pg_init_result" fi info "11.4 50 последовательных INSERT — базовая проверка без ошибок..." seq_ok=0; seq_fail=0 for i in $(seq 1 50); do resp=$(curl -s -m 30 -X POST -H "Content-Type: application/json" \ -d "{\"action\":\"insert\",\"val\":\"seq-${i}\"}" "$FN_BASE/$PG_FN" 2>/dev/null) ok=$(echo "$resp" | python3 -c \ 'import sys,json; print(json.load(sys.stdin).get("ok",False))' 2>/dev/null) [[ "$ok" == "True" ]] && seq_ok=$((seq_ok+1)) || seq_fail=$((seq_fail+1)) done if [[ $seq_ok -eq 50 ]]; then pass "50/50 последовательных INSERT → все OK ✓" else fail "последовательные INSERT: $seq_ok/50 OK, $seq_fail упало" fi info "11.5 COUNT после 50 INSERT → ожидаем ≥ 50..." cnt1=$(pg_count_fn "$PG_FN") if [[ "${cnt1:-0}" -ge 50 ]]; then pass "COUNT=$cnt1 ≥ 50 ✓" else fail "COUNT=$cnt1 < 50 (данные потеряны?)" fi info "11.6 100 параллельных INSERT (pmax=20) — первая волна под давлением..." res6=$(pg_invoke_burst "$PG_FN" 100 20 "insert") s6="${res6#*SUCCESS=}"; s6="${s6%% *}"; s6="${s6:-0}" f6="${res6#*FAIL=}"; f6="${f6:-0}" pct6=$(( s6 * 100 / 100 )) info " Волна 1: $s6/100 OK ($pct6%), $f6 FAIL" if [[ $pct6 -ge 90 ]]; then pass "INSERT волна 1: $s6/100 → ${pct6}% (≥90%) ✓" else fail "INSERT волна 1: $s6/100 → ${pct6}% (<90%)" fi info "11.7 COUNT после волны 1 → ожидаем ≥ 140..." cnt2=$(pg_count_fn "$PG_FN") if [[ "${cnt2:-0}" -ge 140 ]]; then pass "COUNT=$cnt2 ≥ 140 ✓" else fail "COUNT=$cnt2 < 140 (данные теряются под нагрузкой?)" fi info "11.8 150 параллельных INSERT (pmax=30) — вторая волна, жёче..." res8=$(pg_invoke_burst "$PG_FN" 150 30 "insert") s8="${res8#*SUCCESS=}"; s8="${s8%% *}"; s8="${s8:-0}" f8="${res8#*FAIL=}"; f8="${f8:-0}" pct8=$(( s8 * 100 / 150 )) info " Волна 2: $s8/150 OK ($pct8%), $f8 FAIL" if [[ $pct8 -ge 85 ]]; then pass "INSERT волна 2: $s8/150 → ${pct8}% (≥85%) ✓" else fail "INSERT волна 2: $s8/150 → ${pct8}% (<85%)" fi info "11.9 50 параллельных COUNT (pmax=25) — read-нагрузка..." res9=$(pg_invoke_burst "$PG_FN" 50 25 "count") sc="${res9#*SUCCESS=}"; sc="${sc%% *}"; sc="${sc:-0}" pc=$(( sc * 100 / 50 )) if [[ $pc -ge 90 ]]; then pass "COUNT под нагрузкой: $sc/50 → ${pc}% (≥90%) ✓" else fail "COUNT под нагрузкой: $sc/50 → ${pc}% (<90%)" fi info "11.10 Финальный COUNT → ожидаем ≥ 270 (50 + 90%×100 + 85%×150)..." cnt3=$(pg_count_fn "$PG_FN") if [[ "${cnt3:-0}" -ge 270 ]]; then pass "финальный COUNT=$cnt3 ≥ 270 ✓" else fail "финальный COUNT=$cnt3 < 270" fi info "11.11 Удаляем $PG_FN (таблица $PG_TABLE остаётся в PostgreSQL)..." check_code "DELETE $PG_FN" "$(api_code DELETE "$API/services/$PG_FN")" "204" CLEANUP_NAMES=( "${CLEANUP_NAMES[@]/$PG_FN}" ) fi # ═══════════════════════════════════════════════════════════════════════════════ echo "" echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${RESET}" echo -e "${BOLD}║ ИТОГИ SURVIVAL ║${RESET}" echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${RESET}" echo "" echo " Время завершения : $(date '+%Y-%m-%d %H:%M:%S')" echo "" echo " Всего тестов : $((PASS + FAIL))" echo " PASS : $PASS" echo " FAIL : $FAIL" echo "" # Per-group итоги for grp in G5 G6 G7 G8 G9 G10 G11; do g_pass=${GRP_PASS[$grp]:-0} g_fail=${GRP_FAIL[$grp]:-0} g_total=$((g_pass + g_fail)) label="" case "$grp" in G5) label="FLOOD BUILD";; G6) label="RAPID DISCARD STORM";; G7) label="CHURN UNDER FIRE";; G8) label="PHOENIX";; G9) label="SELF-HEALING MARATHON";; G10) label="INVOKE HURRICANE";; G11) label="PG STORM";; esac if [[ $g_fail -eq 0 ]]; then echo -e " ${GREEN}✓${RESET} Группа $grp ($label): ${g_pass}/${g_total}" else echo -e " ${RED}✗${RESET} Группа $grp ($label): ${g_pass}/${g_total} (FAIL: $g_fail)" fi done echo "" if [[ $FAIL -eq 0 ]]; then echo -e " ${GREEN}${BOLD}✓ ВЫЖИЛ — ВСЕ ТЕСТЫ ПРОШЛИ${RESET}" else echo -e " ${RED}${BOLD}✗ ПРОВАЛ — $FAIL тестов упало${RESET}" fi echo ""