#!/usr/bin/env bash # 2026-03-22 — operator_failure_test.sh # ТЕСТ ОТКАЗОУСТОЙЧИВОСТИ — Группа 12: FAILURE MODES # # Проверяет поведение оператора при ошибочном коде/конфиге, который мог # быть загружен пользователем по незнанию или небрежности: # # 12-A API VALIDATION — неверные параметры (memory, name, runtime) → 400 # 12-B UPLOAD FAILURES — испорченный zip, неверный runtime → 400 на upload # 12-C BUILD FAILURES — несуществующий pip-пакет → канико падает → Failed # 12-D CRASH AT STARTUP — синтаксическая ошибка, неверный entrypoint, # отсутствующий модуль → контейнер падает при старте # (ДОКУМЕНТИРУЕТ ПРОБЕЛ: оператор не детектирует # CrashLoopBackOff, фаза остаётся "Ready") # 12-E RUNTIME EXCEPTIONS — handle() бросает исключение → 500 # 12-F INVOKE TIMEOUT — handle() с бесконечным циклом → вызов зависает # 12-G EDGE CASES — memory_mb=1 принимается API; DELETE из Failed # # ВАЖНО ПО РЕЗУЛЬТАТАМ: # PASS — система ведёт себя как ожидается (в т.ч. корректно ловит ошибку) # FAIL — система ведёт себя некорректно (баг или пробел) # [NOTE] — поведение задокументировано, идеал отличается от факта # # ЗАПУСКАТЬ НА VM: # nohup bash ~/terra/sless/operator_failure_test.sh > /tmp/failure.log 2>&1 & # tail -f /tmp/failure.log # # Время прогона: ~25-40 минут (build failure ждёт kaniko ~3 мин). # Зависимости: curl, python3, zip, kubectl set -uo pipefail # ─── CONFIG ────────────────────────────────────────────────────────────────── TOKEN="${SLESS_TOKEN:-$(cat /home/naeel/terra/sless/test.token)}" NS="${SLESS_NS:-sless-ffd1f598c169b0ae}" API="https://sless.kube5s.ru/v1/namespaces/$NS" FN_BASE="https://sless.kube5s.ru/fn/$NS" DEPLOY_NS="sless-fn-$NS" TS=$(date +%s) SFX="fm-${TS}" # fm = failure modes # ─── СТАТИСТИКА ────────────────────────────────────────────────────────────── PASS=0; FAIL=0 declare -A GRP_PASS GRP_FAIL # ─── ЦВЕТА ─────────────────────────────────────────────────────────────────── 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="" 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"; } note() { echo -e " ${YELLOW}[NOTE]${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 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 sleep 5 done return 1 } # wait_failed NAME TIMEOUT_SEC → 0=got Failed 1=timeout 2=got Ready unexpectedly wait_failed() { local name="$1" timeout_sec="${2:-180}" local deadline=$((SECONDS + timeout_sec)) while [[ $SECONDS -lt $deadline ]]; do local phase; phase=$(svc_phase "$name") [[ "$phase" == "Failed" ]] && return 0 [[ "$phase" == "Ready" ]] && return 2 sleep 5 done return 1 } # upload_raw NAME ZIPFILE → HTTP code # Загружает zip через multipart form (стандартный upload endpoint) upload_raw() { 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" } # invoke_one NAME [TIMEOUT] → HTTP code (000 при таймауте/connection refused) invoke_one() { local name="$1" timeout="${2:-30}" curl -s -o /dev/null -w "%{http_code}" -m "$timeout" \ -X POST -H "Content-Type: application/json" \ -d '{"msg":"failure-test"}' \ "$FN_BASE/$name" } # invoke_json NAME [TIMEOUT] → response body invoke_json() { local name="$1" timeout="${2:-30}" curl -s -m "$timeout" \ -X POST -H "Content-Type: application/json" \ -d '{"msg":"failure-test"}' \ "$FN_BASE/$name" } # ─── ZIP GENERATORS ─────────────────────────────────────────────────────────── # make_corrupt_zip → /tmp/corrupt_${SFX}.zip (просто мусорные байты, не zip) make_corrupt_zip() { printf '\xff\xfe\x00\x01THISISNOTAVALIDZIPARCHIVE\xff\xfe\x00\x02garbage\n' \ > "/tmp/corrupt_${SFX}.zip" } # make_empty_valid_zip → /tmp/empty_${SFX}.zip (валидный zip но без файлов) make_empty_valid_zip() { local tmpdir; tmpdir=$(mktemp -d) # zip пустой директории → архив с заголовком но без файлов (cd "$tmpdir" && touch PLACEHOLDER && zip -q "/tmp/empty_${SFX}.zip" PLACEHOLDER) # Удалим placeholder из архива — оставим структуру пустого zip rm -rf "$tmpdir" # Просто создадим минимальный zip без handler.py — это ключевой кейс local tmpdir2; tmpdir2=$(mktemp -d) cat > "$tmpdir2/README.txt" <<'EOF' No handler here. User forgot to put the actual code. EOF (cd "$tmpdir2" && zip -q "/tmp/empty_${SFX}.zip" README.txt) rm -rf "$tmpdir2" } # make_bad_requirements_zip NAME → /tmp/badreq_${NAME}.zip # handler.py нормальный, requirements.txt с несуществующим пакетом → # pip install упадёт → kaniko job failed → phase=Failed make_bad_requirements_zip() { local name="$1" local tmpdir; tmpdir=$(mktemp -d) cat > "$tmpdir/handler.py" <<'PYEOF' def handle(event): return {"ok": True, "msg": "i will never run"} PYEOF # Пакет надуманный — pip не найдёт его → build failure printf 'sless-nonexistent-pkg-xyz==999.0.0\n' > "$tmpdir/requirements.txt" (cd "$tmpdir" && zip -q "/tmp/badreq_${name}.zip" handler.py requirements.txt) rm -rf "$tmpdir" } # make_syntax_error_zip NAME → /tmp/syntax_${NAME}.zip # handler.py с синтаксической ошибкой Python → exec_module() bails at startup make_syntax_error_zip() { local name="$1" local tmpdir; tmpdir=$(mktemp -d) cat > "$tmpdir/handler.py" <<'PYEOF' # Пользователь перепутал скобки — классическая ошибка def handle(event) return {"ok": True} PYEOF (cd "$tmpdir" && zip -q "/tmp/syntax_${name}.zip" handler.py) rm -rf "$tmpdir" } # make_bad_import_zip NAME → /tmp/badimport_${NAME}.zip # handler.py с top-level import несуществующего модуля → # exec_module() падает с ImportError → контейнер падает при старте make_bad_import_zip() { local name="$1" local tmpdir; tmpdir=$(mktemp -d) cat > "$tmpdir/handler.py" <<'PYEOF' # Пользователь написал название библиотеки неправильно (опечатка) import sless_nonexistent_module_xyz_typo as client def handle(event): return {"ok": True} PYEOF (cd "$tmpdir" && zip -q "/tmp/badimport_${name}.zip" handler.py) rm -rf "$tmpdir" } # make_exception_zip NAME → /tmp/exception_${NAME}.zip # handle() всегда бросает исключение → server.py ловит → возвращает 500 make_exception_zip() { local name="$1" local tmpdir; tmpdir=$(mktemp -d) cat > "$tmpdir/handler.py" <<'PYEOF' def handle(event): # Пользователь забыл обработать ошибку и raise всегда срабатывает raise ValueError("something went wrong in user code: division by zero: " + str(1 // 0)) PYEOF (cd "$tmpdir" && zip -q "/tmp/exception_${name}.zip" handler.py) rm -rf "$tmpdir" } # make_infinite_zip NAME → /tmp/infinite_${NAME}.zip # handle() никогда не возвращается → invoke зависает навсегда make_infinite_zip() { local name="$1" local tmpdir; tmpdir=$(mktemp -d) cat > "$tmpdir/handler.py" <<'PYEOF' import time def handle(event): # Пользователь написал бесконечный цикл вместо конечного while True: time.sleep(1) PYEOF (cd "$tmpdir" && zip -q "/tmp/infinite_${name}.zip" handler.py) rm -rf "$tmpdir" } # make_node_bad_export_zip NAME → /tmp/nodebad_${NAME}.zip # Node handler экспортирует неправильное имя — не exports.handle make_node_bad_export_zip() { local name="$1" local tmpdir; tmpdir=$(mktemp -d) cat > "$tmpdir/handler.js" <<'JSEOF' 'use strict'; // Пользователь перепутал имя экспорта — написал "run" вместо "handle" exports.run = function(event) { return { ok: true }; }; JSEOF (cd "$tmpdir" && zip -q "/tmp/nodebad_${name}.zip" handler.js) rm -rf "$tmpdir" } # make_minimal_py_zip NAME → /tmp/minimal_${NAME}.zip # Простой рабочий handler для базовых Deploy-проверок make_minimal_py_zip() { local name="$1" local tmpdir; tmpdir=$(mktemp -d) cat > "$tmpdir/handler.py" <<'PYEOF' def handle(event): return {"ok": True, "test": "failure_modes"} PYEOF (cd "$tmpdir" && zip -q "/tmp/minimal_${name}.zip" handler.py) 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" } # cleanup NAME — удалить сервис cleanup() { local name="$1" [[ -z "$name" ]] && return 0 api_code DELETE "$API/services/$name" > /dev/null 2>&1 || true sleep 2 } 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 FAILURE MODES TEST — sless v0.1.49 ║${RESET}" echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${RESET}" echo -e " Дата : $(TZ=Asia/Dubai date '+%Y-%m-%d %H:%M:%S') (GMT+4)" echo -e " API : $API" echo -e " SFX : $SFX" echo "" # ═══════════════════════════════════════════════════════════════════════════════ section "12A" "API VALIDATION — неверные параметры на входе" # ═══════════════════════════════════════════════════════════════════════════════ # Цель: API должен возвращать 400 на явно мусорные запросы, не создавая CRD. info "12A-1 memory_mb=0 → ожидаем 400..." check_code "memory_mb=0" \ "$(api_code POST "$API/services" \ '{"name":"val-test","runtime":"python3.11","entrypoint":"h.h","memory_mb":0}')" \ "400" info "12A-2 memory_mb=5000 (>4096) → ожидаем 400..." check_code "memory_mb=5000" \ "$(api_code POST "$API/services" \ '{"name":"val-test","runtime":"python3.11","entrypoint":"h.h","memory_mb":5000}')" \ "400" info "12A-3 memory_mb=-1 → ожидаем 400..." check_code "memory_mb=-1" \ "$(api_code POST "$API/services" \ '{"name":"val-test","runtime":"python3.11","entrypoint":"h.h","memory_mb":-1}')" \ "400" info "12A-4 name пустой → ожидаем 400..." check_code "name empty" \ "$(api_code POST "$API/services" \ '{"name":"","runtime":"python3.11","entrypoint":"h.h","memory_mb":128}')" \ "400" info "12A-5 runtime пустой → ожидаем 400..." check_code "runtime empty" \ "$(api_code POST "$API/services" \ '{"name":"val-test","runtime":"","entrypoint":"h.h","memory_mb":128}')" \ "400" info "12A-6 entrypoint пустой → ожидаем 400..." check_code "entrypoint empty" \ "$(api_code POST "$API/services" \ '{"name":"val-test","runtime":"python3.11","entrypoint":"","memory_mb":128}')" \ "400" info "12A-7 невалидный JSON body → ожидаем 400..." check_code "invalid JSON" \ "$(api_code POST "$API/services" 'NOT JSON AT ALL {{{}')" \ "400" info "12A-8 memory_mb=1 → ожидаем 201 (API принимает, нет нижней границы)" code=$(api_code POST "$API/services" \ '{"name":"val-mem1-'"${SFX}"'","runtime":"python3.11","entrypoint":"handler.handle","memory_mb":1}') if [[ "$code" == "201" ]]; then pass "memory_mb=1 → HTTP 201 (создан)" note "GAP: memory_mb=1 принимается API без предупреждения. Контейнер с 1Mi OOMKill'нется." # Удалим сразу — нам build не нужен api_code DELETE "$API/services/val-mem1-${SFX}" > /dev/null 2>&1 || true else fail "memory_mb=1 → HTTP $code (ожидали 201 — нет нижней границы в API)" fi # ═══════════════════════════════════════════════════════════════════════════════ section "12B" "UPLOAD FAILURES — испорченный zip / неверный runtime" # ═══════════════════════════════════════════════════════════════════════════════ # 12B-1: Corrupt zip — случайные байты вместо zip-архива CORRUPT_FN="corrupt-${SFX}" CLEANUP_NAMES+=("$CORRUPT_FN") info "12B-1 Создаём функцию для corrupt zip test..." create_code=$(api_code POST "$API/services" \ "{\"name\":\"$CORRUPT_FN\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}") if [[ "$create_code" == "201" ]]; then make_corrupt_zip info " Upload испорченного файла → ожидаем 400..." uc=$(upload_zip "$CORRUPT_FN" "/tmp/corrupt_${SFX}.zip") if [[ "$uc" == "400" ]]; then pass "corrupt zip → upload HTTP 400 (отклонён на этапе parse zip)" else fail "corrupt zip → upload HTTP $uc (ожидали 400)" fi info " DELETE функции после отклонённого upload → ожидаем 204..." check_code "DELETE after corrupt upload" \ "$(api_code DELETE "$API/services/$CORRUPT_FN")" "204" CLEANUP_NAMES=( "${CLEANUP_NAMES[@]/$CORRUPT_FN}" ) else fail "create $CORRUPT_FN → HTTP $create_code" fi # 12B-2: Неверный runtime ("ruby3.0") — CRD enum validation отклоняет немедленно # v0.1.50: исправлено — services.go теперь маппит k8s IsInvalid → 400 вместо 500 BADRT_FN="badrt-${SFX}" CLEANUP_NAMES+=("$BADRT_FN") info "12B-2 runtime='ruby3.0' → create ожидаем 400 (CRD enum validation)..." create_code2=$(api_code POST "$API/services" \ "{\"name\":\"$BADRT_FN\",\"runtime\":\"ruby3.0\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}") if [[ "$create_code2" == "400" ]]; then pass "runtime=ruby3.0 → create HTTP 400 (CRD enum validation отклонил немедленно)" CLEANUP_NAMES=( "${CLEANUP_NAMES[@]/$BADRT_FN}" ) elif [[ "$create_code2" == "201" ]]; then # Старое поведение (до v0.1.50): API принимал любое имя runtime, ошибка на upload warn "runtime=ruby3.0 → create HTTP 201 (CRD enum validation не работает — проверь CRD)" note "GAP: API принимает любое runtime имя при создании; ошибка только на upload." make_minimal_py_zip "$BADRT_FN" uc2=$(upload_zip "$BADRT_FN" "/tmp/minimal_${BADRT_FN}.zip") if [[ "$uc2" == "400" ]]; then pass "runtime=ruby3.0 → upload HTTP 400 (unsupported runtime отклонён в builder)" else fail "runtime=ruby3.0 → upload HTTP $uc2 (ожидали 400)" fi check_code "DELETE after bad runtime" \ "$(api_code DELETE "$API/services/$BADRT_FN")" "204" CLEANUP_NAMES=( "${CLEANUP_NAMES[@]/$BADRT_FN}" ) else fail "runtime=ruby3.0 → create HTTP $create_code2 (ожидали 400)" fi # 12B-3: Пустой zip (без handler.py) — upload и build проходят, но контейнер падает EMPTY_FN="emptyzip-${SFX}" CLEANUP_NAMES+=("$EMPTY_FN") info "12B-3 Zip без handler.py → upload OK, build OK, но контейнер падает при старте..." create_code3=$(api_code POST "$API/services" \ "{\"name\":\"$EMPTY_FN\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}") if [[ "$create_code3" == "201" ]]; then make_empty_valid_zip uc3=$(upload_zip "$EMPTY_FN" "/tmp/empty_${SFX}.zip") if [[ "$uc3" == "200" ]]; then pass "empty zip (без handler.py) → upload HTTP 200 (builder не проверяет наличие handler)" note "GAP: builder не требует наличия handler.py в zip. Build пройдёт, контейнер упадёт." info " Ждём Ready (build проходит успешно, 180s)..." if wait_phase "$EMPTY_FN" "Ready" 180; then pass "$EMPTY_FN → phase=Ready (build succeed без handler.py)" info " invoke → ожидаем сбой (FileNotFoundError при загрузке handler.py)..." sleep 5 # дать pod время запуститься и упасть inv_code=$(invoke_one "$EMPTY_FN" 15) if [[ "$inv_code" != "200" ]]; then pass "invoke без handler.py → HTTP $inv_code (не 200 — контейнер падает при старте)" note "GAP: фаза='Ready' но функция не работает. Оператор не детектирует CrashLoopBackOff." else fail "invoke без handler.py → HTTP 200 (неожиданно работает?)" fi else warn "$EMPTY_FN не достиг Ready — ждём ещё или уже Failed" fi else fail "empty zip → upload HTTP $uc3 (ожидали 200)" fi fi # ═══════════════════════════════════════════════════════════════════════════════ section "12C" "BUILD FAILURES — несуществующий pip-пакет → phase=Failed" # ═══════════════════════════════════════════════════════════════════════════════ # Цель: проверить что kaniko build failure → оператор переводит в Failed, # а не зависает в Building навсегда. BADPIP_FN="badpip-${SFX}" CLEANUP_NAMES+=("$BADPIP_FN") info "12C-1 Создаём функцию с несуществующим pip-пакетом (sless-nonexistent-pkg-xyz)..." create_code4=$(api_code POST "$API/services" \ "{\"name\":\"$BADPIP_FN\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}") if [[ "$create_code4" == "201" ]]; then make_bad_requirements_zip "$BADPIP_FN" uc4=$(upload_zip "$BADPIP_FN" "/tmp/badreq_${BADPIP_FN}.zip") if [[ "$uc4" == "200" ]]; then pass "bad requirements.txt → upload HTTP 200 (zip валидный, ошибка позже при kaniko)" info " Ждём phase=Failed (kaniko pip install упадёт, до 240s)..." wait_failed_result=0 wait_failed "$BADPIP_FN" 240 || wait_failed_result=$? case "$wait_failed_result" in 0) pass "bad requirements.txt → phase=Failed ✓ (kaniko build failure детектирован)" ;; 2) fail "bad requirements.txt → phase=Ready (build как-то прошёл — неожиданно)" ;; 1) fail "bad requirements.txt → timeout 240s, phase не стало Failed" ;; esac info " 12C-2 DELETE функции в Failed-состоянии → ожидаем 204..." check_code "DELETE in Failed state" \ "$(api_code DELETE "$API/services/$BADPIP_FN")" "204" CLEANUP_NAMES=( "${CLEANUP_NAMES[@]/$BADPIP_FN}" ) pass "DELETE из Failed → 204 (recovery возможен)" else fail "bad requirements.txt → upload HTTP $uc4 (ожидали 200)" fi else fail "create $BADPIP_FN → HTTP $create_code4" fi # ═══════════════════════════════════════════════════════════════════════════════ section "12D" "CRASH AT STARTUP — код ломает контейнер при загрузке" # ═══════════════════════════════════════════════════════════════════════════════ # Сценарии где build успешен, но контейнер падает при первом импорте module. # ФАКТ: оператор показывает phase=Ready, но invoke возвращает не-200. # ИДЕАЛ: оператор должен детектировать CrashLoopBackOff → Failed. # 12D-1: Python syntax error SYNTAX_FN="syntax-${SFX}" CLEANUP_NAMES+=("$SYNTAX_FN") info "12D-1 Python syntax error в handler.py..." c=$(api_code POST "$API/services" \ "{\"name\":\"$SYNTAX_FN\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}") if [[ "$c" == "201" ]]; then make_syntax_error_zip "$SYNTAX_FN" uc=$(upload_zip "$SYNTAX_FN" "/tmp/syntax_${SYNTAX_FN}.zip") if [[ "$uc" == "200" ]]; then pass "syntax error zip → upload 200 (парсер Python не вызывается при build)" info " Ждём Ready (180s)..." if wait_phase "$SYNTAX_FN" "Ready" 180; then pass "$SYNTAX_FN → phase=Ready (build прошёл — kaniko только копирует файлы)" note "GAP: сборка не проверяет синтаксис Python. Ошибка видна только при запуске контейнера." sleep 5 inv_code=$(invoke_one "$SYNTAX_FN" 15) if [[ "$inv_code" != "200" ]]; then pass "syntax error → invoke HTTP $inv_code (не 200, контейнер упал на SyntaxError) ✓" note "GAP: фаза=Ready, но invoke ломается. Нет детектора CrashLoopBackOff в операторе." else fail "syntax error → invoke HTTP 200 (Python как-то выполнил невалидный код?)" fi else warn "$SYNTAX_FN не стал Ready — возможно Failed (неожиданно)" fi else fail "syntax error zip → upload HTTP $uc" fi fi # 12D-2: Неверное имя функции в entrypoint (handler.no_such_function) BADEP_FN="badep-${SFX}" CLEANUP_NAMES+=("$BADEP_FN") info "12D-2 Entrypoint 'handler.no_such_function' — функции не существует..." c2=$(api_code POST "$API/services" \ "{\"name\":\"$BADEP_FN\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.no_such_function\",\"memory_mb\":128}") if [[ "$c2" == "201" ]]; then make_minimal_py_zip "$BADEP_FN" # handler.py есть, но функция называется handle, не no_such_function uc2=$(upload_zip "$BADEP_FN" "/tmp/minimal_${BADEP_FN}.zip") if [[ "$uc2" == "200" ]]; then pass "wrong entrypoint → upload 200" info " Ждём Ready (180s)..." if wait_phase "$BADEP_FN" "Ready" 180; then pass "$BADEP_FN → phase=Ready" note "GAP: API не проверяет что entrypoint совпадает с функцией внутри zip." sleep 5 inv_code2=$(invoke_one "$BADEP_FN" 15) if [[ "$inv_code2" != "200" ]]; then pass "wrong entrypoint → invoke HTTP $inv_code2 (не 200, AttributeError при старте) ✓" else fail "wrong entrypoint → invoke HTTP 200 (как вызвал несуществующую функцию?)" fi else warn "$BADEP_FN не Ready" fi else fail "wrong entrypoint zip → upload HTTP $uc2" fi fi # 12D-3: Top-level неверный import (опечатка в имени библиотеки) BADIMPORT_FN="badimport-${SFX}" CLEANUP_NAMES+=("$BADIMPORT_FN") info "12D-3 'import sless_nonexistent_module_xyz_typo' на верхнем уровне handler.py..." c3=$(api_code POST "$API/services" \ "{\"name\":\"$BADIMPORT_FN\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}") if [[ "$c3" == "201" ]]; then make_bad_import_zip "$BADIMPORT_FN" uc3=$(upload_zip "$BADIMPORT_FN" "/tmp/badimport_${BADIMPORT_FN}.zip") if [[ "$uc3" == "200" ]]; then pass "bad import zip → upload 200" info " Ждём Ready (180s)..." if wait_phase "$BADIMPORT_FN" "Ready" 180; then pass "$BADIMPORT_FN → phase=Ready (build не проверяет импорты)" note "GAP: отсутствующий import на верхнем уровне не виден до старта контейнера." sleep 5 inv_code3=$(invoke_one "$BADIMPORT_FN" 15) if [[ "$inv_code3" != "200" ]]; then pass "bad import → invoke HTTP $inv_code3 (не 200, ImportError при старте) ✓" else fail "bad import → invoke HTTP 200 (неожиданно — модуль как-то нашёлся?)" fi else warn "$BADIMPORT_FN не Ready" fi else fail "bad import zip → upload HTTP $uc3" fi fi # 12D-4: Node.js — неправильный exports.name (exports.run вместо exports.handle) NODEBAD_FN="nodebad-${SFX}" CLEANUP_NAMES+=("$NODEBAD_FN") info "12D-4 Node.js: exports.run вместо exports.handle (неверное имя экспорта)..." c4=$(api_code POST "$API/services" \ "{\"name\":\"$NODEBAD_FN\",\"runtime\":\"nodejs20\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}") if [[ "$c4" == "201" ]]; then make_node_bad_export_zip "$NODEBAD_FN" uc4=$(upload_zip "$NODEBAD_FN" "/tmp/nodebad_${NODEBAD_FN}.zip") if [[ "$uc4" == "200" ]]; then pass "Node.js wrong export → upload 200" info " Ждём Ready (180s)..." if wait_phase "$NODEBAD_FN" "Ready" 180; then pass "$NODEBAD_FN → phase=Ready" note "GAP: Node.js server.js делает process.exit(1) при неверном экспорте → CrashLoop → Ready всё равно." sleep 5 inv_code4=$(invoke_one "$NODEBAD_FN" 15) if [[ "$inv_code4" != "200" ]]; then pass "Node.js wrong export → invoke HTTP $inv_code4 (не 200) ✓" else fail "Node.js wrong export → invoke HTTP 200 (как?" fi else warn "$NODEBAD_FN не Ready" fi else fail "Node.js wrong export → upload HTTP $uc4" fi fi # ═══════════════════════════════════════════════════════════════════════════════ section "12E" "RUNTIME EXCEPTIONS — handle() бросает исключения" # ═══════════════════════════════════════════════════════════════════════════════ # Здесь ошибка происходит внутри handle() — server.py ловит через try/except. # Ожидаемое поведение: invoke возвращает 500, а не EOF/502. EXC_FN="exception-${SFX}" CLEANUP_NAMES+=("$EXC_FN") info "12E-1 Создаём функцию с handle() который всегда бросает ValueError..." c5=$(api_code POST "$API/services" \ "{\"name\":\"$EXC_FN\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}") if [[ "$c5" == "201" ]]; then make_exception_zip "$EXC_FN" uc5=$(upload_zip "$EXC_FN" "/tmp/exception_${EXC_FN}.zip") if [[ "$uc5" == "200" ]]; then pass "exception zip → upload 200" info " Ждём Ready (180s)..." if wait_phase "$EXC_FN" "Ready" 180; then pass "$EXC_FN → phase=Ready" sleep 3 info " 12E-2 invoke → ожидаем 500 (exception в handle() пойман server.py)..." inv_code5=$(invoke_one "$EXC_FN" 30) if [[ "$inv_code5" == "500" ]]; then pass "exception → invoke HTTP 500 ✓ (server.py поймал ValueError, вернул JSON error)" else fail "exception → invoke HTTP $inv_code5 (ожидали 500)" fi info " 12E-3 Повторный invoke → тоже 500, функция не зависает..." inv_code5b=$(invoke_one "$EXC_FN" 30) check_code "repeat exception invoke" "$inv_code5b" "500" info " 12E-4 Ответ 500 содержит поле error с описанием исключения..." response=$(invoke_json "$EXC_FN" 30) has_error=$(echo "$response" | python3 -c \ 'import sys,json; d=json.load(sys.stdin); print("error" in d)' 2>/dev/null) if [[ "$has_error" == "True" ]]; then pass "500 response содержит поле 'error' ✓" else fail "500 response не содержит поле 'error': $response" fi else warn "$EXC_FN не Ready" fi else fail "exception zip → upload HTTP $uc5" fi fi # ═══════════════════════════════════════════════════════════════════════════════ section "12F" "INVOKE TIMEOUT — handle() с бесконечным циклом" # ═══════════════════════════════════════════════════════════════════════════════ # Цель: invoke зависает навсегда, вызывающий получает таймаут (не 502/EOF). # Это тест НА ПОВЕДЕНИЕ вызывающего — оператор не может ограничить выполнение. INF_FN="infinite-${SFX}" CLEANUP_NAMES+=("$INF_FN") info "12F-1 Создаём функцию с бесконечным циклом (while True: sleep(1))..." c6=$(api_code POST "$API/services" \ "{\"name\":\"$INF_FN\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}") if [[ "$c6" == "201" ]]; then make_infinite_zip "$INF_FN" uc6=$(upload_zip "$INF_FN" "/tmp/infinite_${INF_FN}.zip") if [[ "$uc6" == "200" ]]; then pass "infinite loop zip → upload 200" info " Ждём Ready (180s)..." if wait_phase "$INF_FN" "Ready" 180; then pass "$INF_FN → phase=Ready" sleep 3 info " 12F-2 invoke с таймаутом 8s → ожидаем код 000 (curl timeout)..." start_ts=$SECONDS inf_code=$(invoke_one "$INF_FN" 8) elapsed=$((SECONDS - start_ts)) if [[ "$inf_code" == "000" ]]; then pass "infinite loop → invoke timeout за ${elapsed}s, код 000 ✓" note "INFO: оператор не ограничивает время выполнения функции. Ответственность на вызывающем." elif [[ "$inf_code" != "200" ]]; then pass "infinite loop → invoke HTTP $inf_code (не 200, прервало ingress/proxy) за ${elapsed}s" else fail "infinite loop → invoke HTTP 200 за ${elapsed}s (неожиданно вернулся)" fi info " 12F-3 Функция доступна после таймаутного запроса (новый invoke ещё может зависнуть)..." # Проверим что pod живой (не упал) и GET /health работает health_code=$(curl -s -o /dev/null -w "%{http_code}" -m 10 \ "$FN_BASE/$INF_FN" 2>/dev/null || echo "000") if [[ "$health_code" != "200" ]]; then pass "функция принимает новые запросы после предыдущего таймаута (висит снова: $health_code)" fi else warn "$INF_FN не Ready" fi else fail "infinite loop zip → upload HTTP $uc6" fi fi # ═══════════════════════════════════════════════════════════════════════════════ section "12G" "EDGE CASES — компоновка крайних случаев" # ═══════════════════════════════════════════════════════════════════════════════ # 12G-1: DELETE функции которая осталась в Pending (после failed upload) PEND_DEL_FN="pendel-${SFX}" CLEANUP_NAMES+=("$PEND_DEL_FN") info "12G-1 Создаём функцию, не загружаем код, сразу DELETE → ожидаем 204..." api_code POST "$API/services" \ "{\"name\":\"$PEND_DEL_FN\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}" > /dev/null sleep 1 check_code "DELETE Pending function (no upload)" \ "$(api_code DELETE "$API/services/$PEND_DEL_FN")" "204" CLEANUP_NAMES=( "${CLEANUP_NAMES[@]/$PEND_DEL_FN}" ) # 12G-2: Повторное создание с тем же именем после удаления — нет конфликта REUSE_FN="reuse-${SFX}" CLEANUP_NAMES+=("$REUSE_FN") info "12G-2 Создаём, удаляем, повторно создаём с тем же именем → нет конфликта..." c_r1=$(api_code POST "$API/services" \ "{\"name\":\"$REUSE_FN\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}") sleep 1 api_code DELETE "$API/services/$REUSE_FN" > /dev/null 2>&1 sleep 5 # даём finalizer-у отработать c_r2=$(api_code POST "$API/services" \ "{\"name\":\"$REUSE_FN\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}") if [[ "$c_r1" == "201" && "$c_r2" == "201" ]]; then pass "повторное создание '$REUSE_FN' → 201 (нет конфликта имени после DELETE) ✓" else fail "повторное создание: первое=$c_r1 второе=$c_r2 (ожидали оба 201)" fi api_code DELETE "$API/services/$REUSE_FN" > /dev/null 2>&1 CLEANUP_NAMES=( "${CLEANUP_NAMES[@]/$REUSE_FN}" ) # 12G-3: PUT (update) с memory_mb=0 на существующую функцию → 400 UPD_FN="update-${SFX}" CLEANUP_NAMES+=("$UPD_FN") info "12G-3 PUT update с memory_mb=0 на существующую функцию → ожидаем 400..." api_code POST "$API/services" \ "{\"name\":\"$UPD_FN\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}" > /dev/null sleep 1 check_code "PUT memory_mb=0 on existing function" \ "$(api_code PUT "$API/services/$UPD_FN" \ '{"runtime":"python3.11","entrypoint":"handler.handle","memory_mb":0}')" \ "400" api_code DELETE "$API/services/$UPD_FN" > /dev/null 2>&1 CLEANUP_NAMES=( "${CLEANUP_NAMES[@]/$UPD_FN}" ) # 12G-4: GET несуществующего сервиса → 404 info "12G-4 GET несуществующего сервиса → ожидаем 404..." check_code "GET nonexistent function" \ "$(api_code GET "$API/services/totally-nonexistent-fn-xyz-${SFX}")" "404" # 12G-5: DELETE несуществующего сервиса → 404 info "12G-5 DELETE несуществующего сервиса → ожидаем 404..." check_code "DELETE nonexistent function" \ "$(api_code DELETE "$API/services/totally-nonexistent-fn-xyz-${SFX}")" "404" # 12G-6: Дублированное создание (POST с тем же именем без delete) → 409 или 422 DUP_FN="dup-${SFX}" CLEANUP_NAMES+=("$DUP_FN") info "12G-6 Создать дубль (POST с тем же именем без delete) → ожидаем 409/422..." api_code POST "$API/services" \ "{\"name\":\"$DUP_FN\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}" > /dev/null sleep 1 dup_code=$(api_code POST "$API/services" \ "{\"name\":\"$DUP_FN\",\"runtime\":\"python3.11\",\ \"entrypoint\":\"handler.handle\",\"memory_mb\":128}") if [[ "$dup_code" == "409" || "$dup_code" == "422" || "$dup_code" == "400" ]]; then pass "duplicate create → HTTP $dup_code (дубль отклонён) ✓" else fail "duplicate create → HTTP $dup_code (ожидали 409/422, допустимо 400)" fi api_code DELETE "$API/services/$DUP_FN" > /dev/null 2>&1 CLEANUP_NAMES=( "${CLEANUP_NAMES[@]/$DUP_FN}" ) # ═══════════════════════════════════════════════════════════════════════════════ echo "" echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${RESET}" echo -e "${BOLD}║ ИТОГИ FAILURE MODES TEST ║${RESET}" echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${RESET}" echo "" echo " Время завершения : $(TZ=Asia/Dubai date '+%Y-%m-%d %H:%M:%S') (GMT+4)" echo "" echo " Всего тестов : $((PASS + FAIL))" echo " PASS : $PASS" echo " FAIL : $FAIL" echo "" for grp in G12A G12B G12C G12D G12E G12F G12G; do g_pass=${GRP_PASS[$grp]:-0} g_fail=${GRP_FAIL[$grp]:-0} g_total=$((g_pass + g_fail)) label="" case "$grp" in G12A) label="API VALIDATION";; G12B) label="UPLOAD FAILURES";; G12C) label="BUILD FAILURES";; G12D) label="CRASH AT STARTUP";; G12E) label="RUNTIME EXCEPTIONS";; G12F) label="INVOKE TIMEOUT";; G12G) label="EDGE CASES";; 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}✓ FAILURE MODES — система корректно обрабатывает все сценарии${RESET}" else echo -e " ${RED}${BOLD}✗ FAILURE MODES — $FAIL тестов упало (изучить [FAIL] строки выше)${RESET}" fi echo "" echo -e " ${YELLOW}[NOTE]${RESET} Строки [NOTE] выше описывают документированные пробелы в операторе," echo -e " не обязательно баги — это известное поведение для улучшения." echo ""