#!/usr/bin/env bash # 2026-03-21 — operator_lifecycle_test.sh # Тест жизненного цикла ОПЕРАТОРА: CREATE → BUILD → READY → UPDATE → DELETE # # Тестирует не функции, а поведение контроллера: # - Корректность API (валидация) # - Полный цикл одной функции (create → build → ready → update → delete) # - Граничные сценарии (delete during build, reconcile, duplicate) # - Несколько функций одновременно (параллельные builds + delete в середине) # # ЗАПУСКАТЬ НА VM: # ssh naeel@5.172.178.213 'bash ~/terra/sless/operator_lifecycle_test.sh' # # Зависимости: curl, python3, zip, kubectl set -uo pipefail # ─── CONFIG ────────────────────────────────────────────────────────────────── TOKEN=$(cat /home/naeel/terra/sless/test.token) NS="sless-ffd1f598c169b0ae" API="https://sless.kube5s.ru/v1/namespaces/$NS" FN_BASE="https://sless.kube5s.ru/fn/$NS" DEPLOY_NS="sless-fn-$NS" # Метка времени для уникальных имён — избегает коллизий с prod-сервисами TS=$(date +%s) # ─── СТАТИСТИКА ────────────────────────────────────────────────────────────── PASS=0; FAIL=0; KNOWN_BUGS=0 # ─── ЦВЕТА ─────────────────────────────────────────────────────────────────── GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' CYAN='\033[0;36m' BOLD='\033[1m' RESET='\033[0m' # ─── HELPERS ───────────────────────────────────────────────────────────────── pass() { echo -e " ${GREEN}[PASS]${RESET} $1"; ((PASS++)); } fail() { echo -e " ${RED}[FAIL]${RESET} $1"; ((FAIL++)); } # known_bug: баг подтверждён — считаем отдельно, не ломаем failed count known_bug() { echo -e " ${YELLOW}[BUG!]${RESET} $1"; ((KNOWN_BUGS++)); } info() { echo -e " ${CYAN}[INFO]${RESET} $1"; } section() { echo -e "\n${BOLD}━━━ $1 ━━━${RESET}"; } # 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 (JSON) 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 LABEL GOT WANT — сравнить HTTP код 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 (Pending/Building/Ready/Failed/"") 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_PHASE TIMEOUT_SEC # return: 0=ok, 1=timeout, 2=unexpected 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 # Если пришёл Failed а ждали не Failed — стоп if [[ "$phase" == "Failed" && "$want" != "Failed" ]]; then return 2 fi sleep 5 done return 1 } # make_echo_zip NAME → /tmp/lc_echo_{NAME}.zip # Минимальный Python echo: {"ok": true, "env": TEST_VAR, "msg": тело запроса} make_echo_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", ""), "msg": event.get("msg", ""), } PYEOF (cd "$tmpdir" && zip -q "/tmp/lc_echo_${name}.zip" handler.py) rm -rf "$tmpdir" } # upload_code NAME — загрузить zip на /upload, ждать что S3Key принят upload_code() { local name="$1" make_echo_zip "$name" local code code=$(curl -s -o /dev/null -w "%{http_code}" -m 120 \ -H "Authorization: Bearer $TOKEN" \ -F "code=@/tmp/lc_echo_${name}.zip" \ "$API/services/$name/upload") echo "$code" } # create_and_deploy NAME — POST create + upload + wait Ready # return: 0=готов, 1=ошибка create_and_deploy() { local name="$1" # 1. Создать CRD local http_code http_code=$(api_code POST "$API/services" \ "{\"name\":\"$name\",\"display_name\":\"$name lifecycle test\",\ \"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\ \"memory_mb\":128,\"env_vars\":{\"TEST_VAR\":\"initial\"}}") if [[ "$http_code" != "201" ]]; then fail "create $name → HTTP $http_code (ожидали 201)" return 1 fi # 2. Загрузить код local upload_code_result upload_code_result=$(upload_code "$name") if [[ "$upload_code_result" != "200" ]]; then fail "upload $name → HTTP $upload_code_result (ожидали 200)" return 1 fi return 0 } # cleanup NAME — удалить сервис (игнорировать ошибки, для teardown) cleanup() { local name="$1" api_code DELETE "$API/services/$name" > /dev/null 2>&1 || true sleep 2 } # deploy_ns_has NAME — 0 если Deployment существует в DEPLOY_NS deploy_ns_has() { kubectl get deployment -n "$DEPLOY_NS" "$1" --no-headers 2>/dev/null \ | grep -q "$1" } # ─── TEARDOWN (cleanup при выходе) ─────────────────────────────────────────── # Список имён для очистки — добавляется по ходу теста CLEANUP_NAMES=() teardown() { if [[ ${#CLEANUP_NAMES[@]} -gt 0 ]]; then echo -e "\n${CYAN}[INFO]${RESET} teardown: удаляю тестовые сервисы..." for name in "${CLEANUP_NAMES[@]}"; do cleanup "$name" echo -e " ${CYAN}[INFO]${RESET} удалён: $name" done fi } trap teardown EXIT # ═══════════════════════════════════════════════════════════════════════════════ section "ГРУППА 1: Валидация API (без билда)" # ═══════════════════════════════════════════════════════════════════════════════ info "1.1 memory_mb=0 → 400" check_code "memory_mb=0" \ "$(api_code POST "$API/services" \ '{"name":"lc-invalid","runtime":"python3.11","entrypoint":"h.h","memory_mb":0}')" \ "400" info "1.2 memory_mb=5000 → 400" check_code "memory_mb=5000" \ "$(api_code POST "$API/services" \ '{"name":"lc-invalid","runtime":"python3.11","entrypoint":"h.h","memory_mb":5000}')" \ "400" info "1.3 timeout_sec=-1 → 400" check_code "timeout_sec=-1" \ "$(api_code POST "$API/services" \ '{"name":"lc-invalid","runtime":"python3.11","entrypoint":"h.h","memory_mb":128,"timeout_sec":-1}')" \ "400" info "1.4 timeout_sec=901 → 400" check_code "timeout_sec=901" \ "$(api_code POST "$API/services" \ '{"name":"lc-invalid","runtime":"python3.11","entrypoint":"h.h","memory_mb":128,"timeout_sec":901}')" \ "400" info "1.5 без runtime → 400" check_code "без runtime" \ "$(api_code POST "$API/services" \ '{"name":"lc-invalid","entrypoint":"h.h","memory_mb":128}')" \ "400" info "1.6 GET несуществующего → 404" check_code "GET non-existent" \ "$(api_code GET "$API/services/lc-does-not-exist-${TS}")" \ "404" info "1.7 PUT несуществующего → 404" check_code "PUT non-existent" \ "$(api_code PUT "$API/services/lc-does-not-exist-${TS}" \ '{"runtime":"python3.11","entrypoint":"h.h","memory_mb":128}')" \ "404" info "1.8 DELETE несуществующего → 404" check_code "DELETE non-existent" \ "$(api_code DELETE "$API/services/lc-does-not-exist-${TS}")" \ "404" # ═══════════════════════════════════════════════════════════════════════════════ section "ГРУППА 2: Полный жизненный цикл одной функции" # ═══════════════════════════════════════════════════════════════════════════════ SOLO="lc-solo-${TS}" CLEANUP_NAMES+=("$SOLO") info "2.1 CREATE $SOLO → 201" check_code "create $SOLO" \ "$(api_code POST "$API/services" \ "{\"name\":\"$SOLO\",\"display_name\":\"lifecycle solo\",\ \"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\ \"memory_mb\":128,\"env_vars\":{\"TEST_VAR\":\"initial\"}}")" \ "201" info "2.2 UPLOAD код → 200" up_code=$(upload_code "$SOLO") check_code "upload $SOLO" "$up_code" "200" info "2.3 Phase переходит в Building (ждём 60s)..." if wait_phase "$SOLO" "Building" 60; then pass "$SOLO достиг фазы Building" else info "Фаза Building не поймана (возможно уже перешёл дальше, продолжаем)" fi info "2.4 Phase переходит в Ready (ждём 300s — канико + деплой)..." wait_result=0 wait_phase "$SOLO" "Ready" 300 || wait_result=$? if [[ $wait_result -eq 0 ]]; then pass "$SOLO достиг фазы Ready" elif [[ $wait_result -eq 2 ]]; then fail "$SOLO перешёл в Failed (ошибка сборки)" else fail "$SOLO не достиг Ready за 300s (текущая фаза: $(svc_phase "$SOLO"))" fi # phase=Ready ≠ pod запущен: ждём rollout чтобы pod был READY перед invoke info "2.4b Ждём rollout Deployment (pod READY, 90s)..." if kubectl rollout status deployment/"$SOLO" -n "$DEPLOY_NS" --timeout=90s \ > /dev/null 2>&1; then pass "$SOLO pod запущен (rollout OK)" else info "rollout не завершился за 90s, продолжаем" fi sleep 3 info "2.5 INVOKE через /fn/ → 200 + {\"ok\": true}" fn_resp=$(curl -s -m 30 -X POST -H "Content-Type: application/json" \ -d '{"msg":"hello"}' "$FN_BASE/$SOLO") fn_ok=$(echo "$fn_resp" | python3 -c \ "import sys,json; d=json.load(sys.stdin); print(d.get('ok',''))" 2>/dev/null) if [[ "$fn_ok" == "True" ]]; then pass "$SOLO invoke → ok=True" else fail "$SOLO invoke → ответ: $fn_resp" fi info "2.6 Env var передаётся: env=initial" fn_env=$(echo "$fn_resp" | python3 -c \ "import sys,json; d=json.load(sys.stdin); print(d.get('env',''))" 2>/dev/null) if [[ "$fn_env" == "initial" ]]; then pass "$SOLO env=initial" else fail "$SOLO env='$fn_env' (ожидали 'initial')" fi info "2.7 DUPLICATE create пока Ready → 409" check_code "duplicate create (Ready)" \ "$(api_code POST "$API/services" \ "{\"name\":\"$SOLO\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}")" \ "409" info "2.8 PUT: сменить env TEST_VAR=updated → 200" check_code "PUT env update" \ "$(api_code PUT "$API/services/$SOLO" \ "{\"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\ \"memory_mb\":128,\"env_vars\":{\"TEST_VAR\":\"updated\"}}")" \ "200" info "2.9 Ждём rollout Deployment после смены env (60s)..." sleep 15 # дать контроллеру применить обновление if kubectl rollout status deployment/"$SOLO" -n "$DEPLOY_NS" --timeout=60s \ > /dev/null 2>&1; then pass "$SOLO Deployment rollout после env update" else fail "$SOLO Deployment rollout не завершился за 60s" fi info "2.10 Проверяем env в k8s Deployment (TEST_VAR=updated)" k8s_env=$(kubectl get deployment "$SOLO" -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" == "updated" ]]; then pass "k8s Deployment env TEST_VAR=updated ✓" else fail "k8s Deployment env TEST_VAR='$k8s_env' (ожидали 'updated')" fi info "2.11 INVOKE после смены env → env=updated" sleep 5 # дать поду время запуститься fn_resp2=$(curl -s -m 30 -X POST -H "Content-Type: application/json" \ -d '{"msg":"env test"}' "$FN_BASE/$SOLO") fn_env2=$(echo "$fn_resp2" | python3 -c \ "import sys,json; d=json.load(sys.stdin); print(d.get('env',''))" 2>/dev/null) if [[ "$fn_env2" == "updated" ]]; then pass "$SOLO invoke после env update → env=updated" else fail "$SOLO invoke env='$fn_env2' (ожидали 'updated')" fi info "2.12 PUT: сменить memory_mb 128 → 256 → 200" check_code "PUT memory update" \ "$(api_code PUT "$API/services/$SOLO" \ "{\"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\ \"memory_mb\":256,\"env_vars\":{\"TEST_VAR\":\"updated\"}}")" \ "200" info "2.13 KNOWN BUG: k8s Deployment memory limit НЕ обновляется через PUT" sleep 5 k8s_mem=$(kubectl get deployment "$SOLO" -n "$DEPLOY_NS" \ -o jsonpath='{.spec.template.spec.containers[0].resources.limits.memory}' \ 2>/dev/null) if [[ "$k8s_mem" == "256Mi" ]]; then pass "k8s Deployment memory=256Mi (БАГ ПОЧИНЕН или не воспроизводится)" else # Ожидаемый баг: ensureServiceDeployment обновляет только Image+Env, не Resources known_bug "k8s Deployment memory=$k8s_mem (ожидали 256Mi) [БАГ: ensureServiceDeployment не обновляет Resources]" fi info "2.14 PUT: timeout_sec=5 → 200" check_code "PUT timeout_sec=5" \ "$(api_code PUT "$API/services/$SOLO" \ "{\"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\ \"memory_mb\":256,\"env_vars\":{\"TEST_VAR\":\"updated\"},\"timeout_sec\":5}")" \ "200" info "2.15 Phase остаётся Ready после PUT (не триггерит ребилд)" phase_after_put=$(svc_phase "$SOLO") if [[ "$phase_after_put" == "Ready" ]]; then pass "Phase=Ready после PUT (ребилд не запустился)" else fail "Phase=$phase_after_put после PUT (ожидали Ready)" fi info "2.16 PUT: timeout_sec=0 (снять лимит) → 200" check_code "PUT timeout_sec=0 (no limit)" \ "$(api_code PUT "$API/services/$SOLO" \ "{\"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\ \"memory_mb\":256,\"env_vars\":{\"TEST_VAR\":\"updated\"},\"timeout_sec\":0}")" \ "200" info "2.17 DELETE $SOLO → 204" check_code "DELETE $SOLO" \ "$(api_code DELETE "$API/services/$SOLO")" \ "204" info "2.18 GET после DELETE → 404" sleep 3 # дать время finalizer отработать check_code "GET после DELETE" \ "$(api_code GET "$API/services/$SOLO")" \ "404" info "2.19 k8s Deployment удалён (ждём до 30s)" deleted=false for i in $(seq 1 6); do if ! deploy_ns_has "$SOLO"; then deleted=true; break; fi sleep 5 done if $deleted; then pass "k8s Deployment/$SOLO удалён после DELETE" else fail "k8s Deployment/$SOLO ещё существует (finalizer не отработал?)" fi info "2.20 k8s Service удалён" if ! kubectl get service "$SOLO" -n "$DEPLOY_NS" > /dev/null 2>&1; then pass "k8s Service/$SOLO удалён" else fail "k8s Service/$SOLO ещё существует" fi # ═══════════════════════════════════════════════════════════════════════════════ section "ГРУППА 3: Граничные сценарии" # ═══════════════════════════════════════════════════════════════════════════════ # ── 3A: DELETE пока Building ───────────────────────────────────────────────── KILL_BUILD="lc-killbuild-${TS}" CLEANUP_NAMES+=("$KILL_BUILD") info "3.1 Создаём $KILL_BUILD и сразу удаляем пока в Building..." http_create=$(api_code POST "$API/services" \ "{\"name\":\"$KILL_BUILD\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128,\"env_vars\":{}}") check_code "create $KILL_BUILD" "$http_create" "201" # Загружаем код — это запускает kaniko up_kb=$(upload_code "$KILL_BUILD") check_code "upload $KILL_BUILD" "$up_kb" "200" info " Ждём фазу Building (макс 90s)..." if wait_phase "$KILL_BUILD" "Building" 90; then pass " $KILL_BUILD вошёл в фазу Building" info " DELETE $KILL_BUILD пока Building..." check_code "DELETE while Building" \ "$(api_code DELETE "$API/services/$KILL_BUILD")" \ "204" info " Ждём исчезновения CRD (finalizer убивает kaniko, 30s)..." sleep 10 check_code "GET после DELETE while Building" \ "$(api_code GET "$API/services/$KILL_BUILD")" \ "404" info " Deployment НЕ должен был создаться" if ! deploy_ns_has "$KILL_BUILD"; then pass "Deployment/$KILL_BUILD не существует (правильно: убит до Ready)" else fail "Deployment/$KILL_BUILD существует (неожиданно: функция не дошла до Ready)" fi else info " Фаза Building не поймана за 90s (уже завершился). Пропускаем 3A" cleanup "$KILL_BUILD" fi # ── 3B: Reconcile — восстановление вручную удалённого Deployment ───────────── RECONCILE="lc-reconcile-${TS}" CLEANUP_NAMES+=("$RECONCILE") info "3.2 Reconcile: создаём $RECONCILE, ждём Ready, удаляем Deployment вручную" create_and_deploy "$RECONCILE" info " Ждём Ready (300s)..." wait_reconcile=0 wait_phase "$RECONCILE" "Ready" 300 || wait_reconcile=$? if [[ $wait_reconcile -eq 0 ]]; then pass "$RECONCILE готов" kubectl rollout status deployment/"$RECONCILE" -n "$DEPLOY_NS" --timeout=60s \ > /dev/null 2>&1 || true info " kubectl delete deployment — симулируем сбой в k8s..." kubectl delete deployment "$RECONCILE" -n "$DEPLOY_NS" > /dev/null 2>&1 info " Ждём пока контроллер пересоздаст Deployment (90s)..." recreated=false for i in $(seq 1 18); do sleep 5 if deploy_ns_has "$RECONCILE"; then recreated=true; break; fi done if $recreated; then pass "Deployment/$RECONCILE пересоздан контроллером (reconcile работает)" else # БАГ: контроллер не реагирует на удаление Deployment — нет watch cross-namespace. # ensureServiceDeployment вызывается только при изменении Service CRD. # Без RequeueAfter или Owns() в controller-runtime self-healing не работает. known_bug "Deployment/$RECONCILE не пересоздан за 90s [БАГ: нет self-healing — контроллер не смотрит за Deployment в sless-fn-* namespace]" fi info " Invoke (прямой kubectl port-forward не доступен, ожидаем 502/200)..." # Если reconcile не пересоздал — вернёт 502 (нет pod), если пересоздал — 200 sleep 5 fn_reconcile=$(curl -s -o /dev/null -w "%{http_code}" -m 30 \ -X POST -H "Content-Type: application/json" \ -d '{}' "$FN_BASE/$RECONCILE") info " invoke после reconcile → HTTP $fn_reconcile (200=работает, 502=pod отсутствует)" if [[ "$fn_reconcile" == "200" ]]; then pass "invoke после reconcile → 200 (pod пересоздан)" else info " invoke → $fn_reconcile (self-healing не сработал — известный баг)" fi else fail "$RECONCILE не достиг Ready, пропускаем 3B" fi info " Удаляем $RECONCILE" check_code "DELETE $RECONCILE" \ "$(api_code DELETE "$API/services/$RECONCILE")" "204" # ═══════════════════════════════════════════════════════════════════════════════ section "ГРУППА 4: Несколько функций одновременно" # ═══════════════════════════════════════════════════════════════════════════════ MULTI1="lc-multi1-${TS}" MULTI2="lc-multi2-${TS}" MULTI3="lc-multi3-${TS}" CLEANUP_NAMES+=("$MULTI1" "$MULTI2" "$MULTI3") info "4.1 Создаём 3 функции параллельно..." # Параллельный create + upload. Каждую в фоне ( set +e http=$(api_code POST "$API/services" \ "{\"name\":\"$MULTI1\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128,\"env_vars\":{\"IDX\":\"1\"}}") if [[ "$http" == "201" ]]; then upload_code "$MULTI1" > /dev/null fi ) & ( set +e http=$(api_code POST "$API/services" \ "{\"name\":\"$MULTI2\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128,\"env_vars\":{\"IDX\":\"2\"}}") if [[ "$http" == "201" ]]; then upload_code "$MULTI2" > /dev/null fi ) & ( set +e http=$(api_code POST "$API/services" \ "{\"name\":\"$MULTI3\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128,\"env_vars\":{\"IDX\":\"3\"}}") if [[ "$http" == "201" ]]; then upload_code "$MULTI3" > /dev/null fi ) & wait # ждём все три create+upload # Ждём пока контроллер выставит фазу (upload завершён, но reconcile асинхронный) sleep 15 info " Проверяем что все три CRD созданы..." m1_phase=$(svc_phase "$MULTI1") m2_phase=$(svc_phase "$MULTI2") m3_phase=$(svc_phase "$MULTI3") info " $MULTI1: $m1_phase | $MULTI2: $m2_phase | $MULTI3: $m3_phase" if [[ "$m1_phase" != "" && "$m2_phase" != "" && "$m3_phase" != "" ]]; then pass "все 3 сервиса созданы (фазы: $m1_phase / $m2_phase / $m3_phase)" else fail "не все сервисы созданы ($MULTI1=$m1_phase $MULTI2=$m2_phase $MULTI3=$m3_phase)" fi info "4.2 Ждём пока $MULTI2 войдёт в Building, затем удалим его..." delete_done=false deadline_kill=$((SECONDS + 120)) while [[ $SECONDS -lt $deadline_kill ]]; do phase2=$(svc_phase "$MULTI2") if [[ "$phase2" == "Building" ]]; then info " $MULTI2 в Building — удаляем!" check_code "DELETE $MULTI2 while Building" \ "$(api_code DELETE "$API/services/$MULTI2")" \ "204" delete_done=true break fi if [[ "$phase2" == "Ready" || "$phase2" == "Failed" ]]; then info " $MULTI2 уже $phase2 (пропустили Building) — удаляем всё равно" check_code "DELETE $MULTI2 (post-build)" \ "$(api_code DELETE "$API/services/$MULTI2")" \ "204" delete_done=true break fi sleep 5 done if ! $delete_done; then info " $MULTI2 не нашли → видимо уже завершён, пробуем delete" api_code DELETE "$API/services/$MULTI2" > /dev/null 2>&1 || true fi info "4.3 Ждём Ready для $MULTI1 и $MULTI3 (300s)..." r1=0; r3=0 wait_phase "$MULTI1" "Ready" 300 || r1=$? wait_phase "$MULTI3" "Ready" 300 || r3=$? if [[ $r1 -eq 0 ]]; then pass "$MULTI1 → Ready"; else fail "$MULTI1 не Ready ($(svc_phase "$MULTI1"))"; fi if [[ $r3 -eq 0 ]]; then pass "$MULTI3 → Ready"; else fail "$MULTI3 не Ready ($(svc_phase "$MULTI3"))"; fi # Ждём rollout у обоих чтобы pods READY перед параллельным invoke kubectl rollout status deployment/"$MULTI1" -n "$DEPLOY_NS" --timeout=60s \ > /dev/null 2>&1 || true kubectl rollout status deployment/"$MULTI3" -n "$DEPLOY_NS" --timeout=60s \ > /dev/null 2>&1 || true sleep 3 info "4.4 $MULTI2 → 404 после удаления" check_code "$MULTI2 GET после удаления" \ "$(api_code GET "$API/services/$MULTI2")" \ "404" info "4.5 Параллельный invoke: 20 запросов к $MULTI1 и $MULTI3 одновременно..." success=0; fail_count=0 invoke_one() { local name="$1" local code code=$(curl -s -o /dev/null -w "%{http_code}" -m 30 \ -X POST -H "Content-Type: application/json" \ -d '{}' "$FN_BASE/$name") echo "$code" } # Каждый subshell пишет в отдельный файл — нет race condition при параллельной записи results_dir=$(mktemp -d) for i in $(seq 1 20); do ( r1_code=$(invoke_one "$MULTI1") r3_code=$(invoke_one "$MULTI3") echo "$r1_code" > "$results_dir/m1_${i}" echo "$r3_code" > "$results_dir/m3_${i}" ) & done wait for f in "$results_dir"/*; do code=$(cat "$f") # Избегаем ((success++)) в && цепочке: post-increment при value=0 → exit 1 → || срабатывает if [[ "$code" == "200" ]]; then success=$((success + 1)) else fail_count=$((fail_count + 1)) fi done rm -rf "$results_dir" if [[ $fail_count -eq 0 ]]; then pass "40 параллельных invoke: $success/40 → 200 ✓" else fail "40 параллельных invoke: $fail_count ошибок (${success}/40 OK)" fi info "4.6 Удаляем $MULTI1 и $MULTI3..." check_code "DELETE $MULTI1" "$(api_code DELETE "$API/services/$MULTI1")" "204" check_code "DELETE $MULTI3" "$(api_code DELETE "$API/services/$MULTI3")" "204" # Убрать из CLEANUP_NAMES чтобы teardown не пытался удалить повторно CLEANUP_NAMES=() # ═══════════════════════════════════════════════════════════════════════════════ echo "" echo -e "${BOLD}━━━ ИТОГИ ━━━${RESET}" echo "" echo " Всего тестов : $((PASS + FAIL))" echo " PASS : $PASS" echo " FAIL : $FAIL" if [[ $KNOWN_BUGS -gt 0 ]]; then echo -e " ${YELLOW}KNOWN BUGS : $KNOWN_BUGS${RESET} ← баги в коде оператора, не в тестах" fi echo "" if [[ $FAIL -eq 0 ]]; then echo -e " ${GREEN}✓ ВСЕ ТЕСТЫ ПРОШЛИ${RESET}" else echo -e " ${RED}✗ ЕСТЬ ПАДЕНИЯ — $FAIL тестов${RESET}" fi if [[ $KNOWN_BUGS -gt 0 ]]; then echo -e " ${YELLOW}⚠ НАЙДЕНЫ БАГИ В ОПЕРАТОРЕ — $KNOWN_BUGS штук${RESET}" fi echo ""