Files
sless/operator_failure_test.sh
Naeel a76baa62a3 fix: v0.1.50 — runtime 400 + SLESS_ENTRYPOINT в Deployment
Bug 1: services.go — k8s IsInvalid error (CRD enum validation) маппился в 500.
Теперь errors.IsInvalid() → 400 Bad Request (invalid service spec).

Bug 2: service_controller.go buildServiceDeployment не передавал env SLESS_ENTRYPOINT
в под. Добавлен в envVars из svc.Spec.Entrypoint. Без него server.py использовал
fallback handler.handle и не замечал неверный entrypoint.

operator_failure_test.sh 12B-2: обновлён под новое правильное поведение —
create ruby3.0 → 400 (не 201). Старый 201-путь сохранён как warn для совместимости.
2026-03-22 08:11:05 +03:00

867 lines
44 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 ""