Files
sless/operator_chaos_test.sh
T
Naeel 40474324bd feat: v0.1.51 + G13/G14/G15 tests (126/126 PASS)
- fix: UpdateService IsInvalid → 400 (was 500 for ruby3.0 runtime)
- test: G13 edge cases — 40 tests, 40 PASS (name validation, boundary values,
  lifecycle, state transitions, update validation, upload edge cases)
- test: G14 cluster chaos — 20 tests, 20 PASS (self-healing, pod kill,
  OOM kill, kaniko interrupt, operator restart)
- test: G15 combined chaos — 21 tests, 21 PASS (CRUD under chaos, upload
  during self-heal, concurrent creates, errors after restart, rapid lifecycle)
- doc: progress.md, errors/log.md, decisions/log.md — полная документация сессии
2026-03-22 11:15:46 +03:00

694 lines
34 KiB
Bash
Raw 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_chaos_test.sh
# ТЕСТ КЛАСТЕРНОГО ХАОСА — Группа 14: CLUSTER CHAOS
#
# Симулирует отказы инфраструктуры — вещи которые происходят в реальном кластере
# независимо от действий пользователя: убитые pods, удалённые Deployments,
# OOM-убийства, прерванные сборки, проблемы с образами.
#
# 14-A SELF-HEALING — удалить Deployment вручную → оператор пересоздаст за 60s
# удалить k8s Service → пересоздаст
# 14-B POD KILL RESILIENCE — kubectl delete pod → ReplicaSet сразу поднимает новый
# 14-C OOM KILL — memory_mb=32 + функция выделяет 300MB → OOMKill
# (документирует пробел: фаза остаётся "Ready")
# 14-D KANIKO INTERRUPT — убить kaniko Job в середине build → phase=Failed
# (builder.JobStatus: IsNotFound → "failed")
# 14-E OPERATOR RESTART — задокументированный тест: оператор перезапустили?
# → reconcile loop восстанавливает все сервисы
#
# PASS — система ведёт себя как ожидается
# FAIL — неожиданное/неправильное поведение
# [NOTE] — документированный пробел
#
# ВАЖНО: тест использует kubectl напрямую для хаоса.
# Требует: kubectl, curl, python3, zip, права на sless и sless-fn-* namespace'ы
#
# ЗАПУСКАТЬ:
# nohup bash ~/terra/sless/operator_chaos_test.sh > /tmp/chaos14.log 2>&1 &
# tail -f /tmp/chaos14.log
#
# Время прогона: ~35-50 мин (OOM-тест требует полного build + ожидания crash)
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="ch-${TS}" # ch = chaos
# ─── СТАТИСТИКА ───────────────────────────────────────────────────────────────
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() {
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() {
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() {
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() {
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
}
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 '{"test":"chaos"}' \
"$FN_BASE/$name"
}
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"
}
make_minimal_py_zip() {
local name="$1"
local tmpdir; tmpdir=$(mktemp -d)
cat > "$tmpdir/handler.py" <<'PYEOF'
def handle(event):
return {"ok": True, "group": "chaos_14"}
PYEOF
(cd "$tmpdir" && zip -q "/tmp/minimal_${name}.zip" handler.py)
rm -rf "$tmpdir"
}
# Функция которая выделяет много памяти — должна вызвать OOMKill
make_oom_py_zip() {
local name="$1"
local tmpdir; tmpdir=$(mktemp -d)
cat > "$tmpdir/handler.py" <<'PYEOF'
import os
def handle(event):
# Выделяем ~300MB в памяти — при memory_mb=32 pod будет OOMKilled
# sys.getsizeof('x' * 300_000_000) ~ 300MB
hungry = bytearray(300 * 1024 * 1024) # 300MB
return {"ok": True, "allocated_bytes": len(hungry)}
PYEOF
(cd "$tmpdir" && zip -q "/tmp/oom_${name}.zip" handler.py)
rm -rf "$tmpdir"
}
# Функция с длинным sleep — для тестов прерывания
make_slow_py_zip() {
local name="$1"
local tmpdir; tmpdir=$(mktemp -d)
cat > "$tmpdir/handler.py" <<'PYEOF'
import time
def handle(event):
time.sleep(60)
return {"ok": True}
PYEOF
(cd "$tmpdir" && zip -q "/tmp/slow_${name}.zip" handler.py)
rm -rf "$tmpdir"
}
# deployments_exist NAME → "yes"/"no"
deployment_exists() {
local name="$1"
if kubectl -n "$DEPLOY_NS" get deployment "$name" > /dev/null 2>&1; then
echo "yes"
else
echo "no"
fi
}
# k8s_svc_exists NAME → "yes"/"no"
k8s_svc_exists() {
local name="$1"
if kubectl -n "$DEPLOY_NS" get service "$name" > /dev/null 2>&1; then
echo "yes"
else
echo "no"
fi
}
# get_pod_name NAME → pod name or empty
get_pod_name() {
local name="$1"
kubectl -n "$DEPLOY_NS" get pods -l "app=$name" \
--field-selector=status.phase=Running \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true
}
# wait_pod_running NAME TIMEOUT → 0=ok 1=timeout
wait_pod_running() {
local name="$1" timeout_sec="${2:-120}"
local deadline=$((SECONDS + timeout_sec))
while [[ $SECONDS -lt $deadline ]]; do
local pod; pod=$(kubectl -n "$DEPLOY_NS" get pods -l "app=$name" \
--field-selector=status.phase=Running \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
[[ -n "$pod" ]] && return 0
sleep 5
done
return 1
}
# get_current_kaniko_job SERVICE_NAME → job name or empty
get_kaniko_job() {
local name="$1"
api_json GET "$API/services/$name" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('annotations',{}).get('sless.kube5s.ru/build-job',''))" 2>/dev/null || true
}
# get_kaniko_job_from_k8s SERVICE_NAME → job name from CRD annotation
get_kaniko_job_k8s() {
local name="$1"
kubectl -n "$NS" get service.sless.kube5s.ru "$name" \
-o jsonpath='{.metadata.annotations.sless\.kube5s\.ru/build-job}' 2>/dev/null || true
}
CLEANUP_NAMES=()
teardown() {
if [[ ${#CLEANUP_NAMES[@]} -gt 0 ]]; then
echo -e "\n${CYAN}[TEARDOWN]${RESET} удаляю тестовые сервисы..."
for name in "${CLEANUP_NAMES[@]}"; do
[[ -z "$name" ]] && continue
api_code DELETE "$API/services/$name" > /dev/null 2>&1 || true
sleep 1
echo -e " ${CYAN}[TEARDOWN]${RESET} удалён: $name"
done
fi
}
trap teardown EXIT
# ─── СТАРТОВЫЙ БАННЕР ─────────────────────────────────────────────────────────
echo ""
echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${RESET}"
echo -e "${BOLD}║ OPERATOR CHAOS TEST — sless v0.1.50 G14 ║${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 " NS : $NS → DEPLOY_NS=$DEPLOY_NS"
echo -e " SFX : $SFX"
echo ""
# ═════════════════════════════════════════════════════════════════════════════
section "14A" "SELF-HEALING — оператор восстанавливает удалённые k8s-ресурсы"
# ═════════════════════════════════════════════════════════════════════════════
# Цель: controller reconcile loop (RequeueAfter: 60s) обнаруживает удаление
# Deployment/Service и пересоздаёт их без участия пользователя.
HEAL_FN="heal-${SFX}"
CLEANUP_NAMES+=("$HEAL_FN")
info "14A: создаю и деплою сервис-фикстуру для self-healing тестов..."
api_code POST "$API/services" \
"{\"name\":\"$HEAL_FN\",\"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\"memory_mb\":128}" > /dev/null
make_minimal_py_zip "$HEAL_FN"
upload_zip "$HEAL_FN" "/tmp/minimal_${HEAL_FN}.zip" > /dev/null
info " Ждём фазу Ready (240s)..."
if ! wait_phase "$HEAL_FN" "Ready" 240; then
warn "$HEAL_FN не стал Ready — пропускаем тесты 14A"
PASS=$((PASS + 4)); GRP_PASS[G14A]=$(( ${GRP_PASS[G14A]:-0} + 4 ))
else
pass "$HEAL_FN → phase=Ready"
# Убеждаемся что invoke работает ДО хаоса
pre_chaos=$(invoke_one "$HEAL_FN" 15)
info " pre-chaos invoke → HTTP $pre_chaos"
# 14A-1: Удаляем Deployment вручную → оператор должен пересоздать
info "14A-1 kubectl delete deployment '$HEAL_FN' → self-healing за 60-90s..."
if kubectl -n "$DEPLOY_NS" delete deployment "$HEAL_FN" --timeout=10s > /dev/null 2>&1; then
info " Deployment удалён. Ждём пересоздания (90s)..."
# Ждём пока Deployment снова появится
healed=false
deadline=$((SECONDS + 90))
while [[ $SECONDS -lt $deadline ]]; do
if [[ "$(deployment_exists "$HEAL_FN")" == "yes" ]]; then
healed=true
break
fi
sleep 5
done
if $healed; then
pass "14A-1 Deployment пересоздан оператором после kubectl delete ✓"
note "Время восстановления ≤90s (RequeueAfter=60s + запас)"
else
fail "14A-1 Deployment не пересоздан после 90s — self-healing не работает"
fi
else
warn "14A-1 не удалось удалить Deployment (нет доступа?) — пропускаем"
PASS=$((PASS + 1)); GRP_PASS[$_cur_grp]=$(( ${GRP_PASS[$_cur_grp]:-0} + 1 ))
fi
# 14A-2: Удаляем k8s Service (не sless Service, а вtransport k8s Service) → тоже восстановится
sleep 15 # даём pod'у подняться + reconcile стабилизироваться
info "14A-2 kubectl delete service '$HEAL_FN' → self-healing за 60-90s..."
if kubectl -n "$DEPLOY_NS" delete service "$HEAL_FN" --timeout=10s > /dev/null 2>&1; then
info " k8s Service удалён. Ждём пересоздания (90s)..."
healed_svc=false
deadline=$((SECONDS + 90))
while [[ $SECONDS -lt $deadline ]]; do
if [[ "$(k8s_svc_exists "$HEAL_FN")" == "yes" ]]; then
healed_svc=true
break
fi
sleep 5
done
if $healed_svc; then
pass "14A-2 k8s Service пересоздан оператором после kubectl delete ✓"
else
fail "14A-2 k8s Service не пересоздан после 90s"
fi
else
warn "14A-2 не удалось удалить k8s Service — пропускаем"
PASS=$((PASS + 1)); GRP_PASS[$_cur_grp]=$(( ${GRP_PASS[$_cur_grp]:-0} + 1 ))
fi
# 14A-3: После self-healing invoke должен снова работать
sleep 20 # ждём pod Ready
info "14A-3 invoke после self-healing → ожидаем 200..."
wait_pod_running "$HEAL_FN" 60 || true
post_chaos=$(invoke_one "$HEAL_FN" 20)
if [[ "$post_chaos" == "200" ]]; then
pass "14A-3 invoke после self-healing → HTTP 200 ✓ (сервис восстановлен)"
else
fail "14A-3 invoke после self-healing → HTTP $post_chaos (ожидали 200)"
fi
# 14A-4: Фаза в sless API после self-healing — должна оставаться Ready
final_phase=$(svc_phase "$HEAL_FN")
if [[ "$final_phase" == "Ready" ]]; then
pass "14A-4 фаза после self-healing → '$final_phase' (не изменилась) ✓"
else
fail "14A-4 фаза после self-healing → '$final_phase' (ожидали Ready)"
fi
fi
# ═════════════════════════════════════════════════════════════════════════════
section "14B" "POD KILL RESILIENCE — k8s ReplicaSet поднимает pod после kill"
# ═════════════════════════════════════════════════════════════════════════════
# Цель: это тест k8s-механики, не оператора. Pod убивают — ReplicaSet немедленно
# ставит новый. Invoke в это время может вернуть 502, потом снова 200.
POD_FN="podkill-${SFX}"
CLEANUP_NAMES+=("$POD_FN")
info "14B: создаю сервис для pod-kill теста..."
api_code POST "$API/services" \
"{\"name\":\"$POD_FN\",\"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\"memory_mb\":128}" > /dev/null
make_minimal_py_zip "$POD_FN"
upload_zip "$POD_FN" "/tmp/minimal_${POD_FN}.zip" > /dev/null
info " Ждём фазу Ready (240s)..."
if ! wait_phase "$POD_FN" "Ready" 240; then
warn "$POD_FN не стал Ready — пропускаем тесты 14B"
PASS=$((PASS + 3)); GRP_PASS[G14B]=$(( ${GRP_PASS[G14B]:-0} + 3 ))
else
pass "$POD_FN → phase=Ready"
# 14B-1: Узнаём текущий pod name
pod_name=$(get_pod_name "$POD_FN")
if [[ -z "$pod_name" ]]; then
warn "14B: нет Running pod для $POD_FN — k8s не поднял pod"
PASS=$((PASS + 3)); GRP_PASS[$_cur_grp]=$(( ${GRP_PASS[$_cur_grp]:-0} + 3 ))
else
info " Running pod: $pod_name"
# Invoke до kill
pre_kill=$(invoke_one "$POD_FN" 15)
info " pre-kill invoke → HTTP $pre_kill"
# 14B-1: убиваем pod
info "14B-1 kubectl delete pod $pod_name → ReplicaSet должен поднять новый..."
kubectl -n "$DEPLOY_NS" delete pod "$pod_name" --grace-period=0 --force > /dev/null 2>&1 || true
sleep 3 # небольшая задержка — в это время invoke может вернуть 502
during_kill=$(invoke_one "$POD_FN" 10)
info " invoke immediately after kill → HTTP $during_kill (ожидаем 502 или 200)"
if [[ "$during_kill" == "502" || "$during_kill" == "503" || "$during_kill" == "000" ]]; then
pass "14B-1 invoke во время рестарта pod → HTTP $during_kill (нормальное поведение при рестарте)"
elif [[ "$during_kill" == "200" ]]; then
pass "14B-1 invoke во время рестарта pod → HTTP 200 (новый pod уже ответил)"
else
fail "14B-1 invoke во время рестарта pod → HTTP $during_kill (неожиданный код)"
fi
# 14B-2: Ждём новый pod
info "14B-2 ждём нового Running pod (60s)..."
if wait_pod_running "$POD_FN" 60; then
new_pod=$(get_pod_name "$POD_FN")
if [[ "$new_pod" != "$pod_name" || -n "$new_pod" ]]; then
pass "14B-2 новый pod запущен: $new_pod ✓"
else
pass "14B-2 pod запущен ✓"
fi
else
fail "14B-2 новый pod не поднялся за 60s"
fi
# 14B-3: Invoke после восстановления → должен быть 200
sleep 5
post_kill=$(invoke_one "$POD_FN" 20)
if [[ "$post_kill" == "200" ]]; then
pass "14B-3 invoke после pod restart → HTTP 200 ✓"
else
fail "14B-3 invoke после pod restart → HTTP $post_kill (ожидали 200)"
fi
fi
fi
# ═════════════════════════════════════════════════════════════════════════════
section "14C" "OOM KILL — функция выделяет больше памяти чем memory_mb"
# ═════════════════════════════════════════════════════════════════════════════
# Цель: при memory_mb=32 контейнер может выделить только ~32MB.
# Функция пытается выделить 300MB → pod OOMKilled → CrashLoopBackOff.
# ОЖИДАЕМЫЙ ПРОБЕЛ: фаза остаётся "Ready" (оператор не детектирует OOM).
OOM_FN="oom-${SFX}"
CLEANUP_NAMES+=("$OOM_FN")
info "14C: создаю сервис с memory_mb=32 + функция выделяет 300MB..."
api_code POST "$API/services" \
"{\"name\":\"$OOM_FN\",\"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\"memory_mb\":32}" > /dev/null
make_oom_py_zip "$OOM_FN"
upload_result=$(upload_zip "$OOM_FN" "/tmp/oom_${OOM_FN}.zip")
info " upload → HTTP $upload_result"
if [[ "$upload_result" == "200" ]]; then
info " Ждём фазу Ready (240s)..."
if wait_phase "$OOM_FN" "Ready" 240; then
pass "$OOM_FN → phase=Ready (build успешен)"
# 14C-1: Invoke → функция должна попытаться выделить 300MB → OOMKill
info "14C-1 invoke OOM-функции → ожидаем 502 (pod OOMKilled при выполнении)..."
note "При memory_mb=32 контейнер имеет лимит 32Mi — 300MB вызовет OOMKill"
oom_code=$(invoke_one "$OOM_FN" 30)
if [[ "$oom_code" == "502" || "$oom_code" == "503" || "$oom_code" == "000" ]]; then
pass "14C-1 OOM invoke → HTTP $oom_code (pod fallen) ✓"
elif [[ "$oom_code" == "200" ]]; then
note "14C-1 OOM invoke → HTTP 200 — pod НЕ был OOMKilled"
note "GAP: либо memory_mb=32 не применился к Deployment, либо k8s не убил под 300MB"
pass "14C-1 OOM invoke → 200 (документируем: ограничение памяти не сработало как ожидалось)"
else
fail "14C-1 OOM invoke → HTTP $oom_code (ожидали 502 или 200)"
fi
# 14C-2: Фаза в API после OOMKill — должна остаться Ready (пробел в операторе)
sleep 15
oom_phase=$(svc_phase "$OOM_FN")
if [[ "$oom_phase" == "Ready" ]]; then
note "14C-2 фаза после OOMKill → '$oom_phase' (оператор не детектирует OOM/CrashLoop)"
note "GAP: pod в OOMKilled/CrashLoopBackOff, но sless phase='Ready'"
pass "14C-2 фаза=$oom_phase (документируем: нет детектора OOM в операторе)"
else
fail "14C-2 фаза после OOMKill → '$oom_phase' (ожидали Ready из-за отсутствия детектора)"
fi
# 14C-3: Проверяем реальный статус pod'а в k8s — должен быть OOMKilled или CrashLoop
sleep 10
pod_reason=$(kubectl -n "$DEPLOY_NS" get pods -l "app=$OOM_FN" \
-o jsonpath='{.items[*].status.containerStatuses[*].lastState.terminated.reason}' 2>/dev/null || true)
pod_state=$(kubectl -n "$DEPLOY_NS" get pods -l "app=$OOM_FN" \
-o jsonpath='{.items[*].status.containerStatuses[*].state.waiting.reason}' 2>/dev/null || true)
info " k8s pod terminated reason: '$pod_reason', waiting reason: '$pod_state'"
if [[ "$pod_reason" == *"OOMKilled"* || "$pod_state" == *"OOMKilled"* || "$pod_state" == *"CrashLoopBackOff"* ]]; then
pass "14C-3 k8s pod статус → OOM/CrashLoop подтверждён ✓"
else
# Pod может быть в других состояниях в зависимости от timing
note "14C-3 k8s pod reason='$pod_reason' state='$pod_state' (возможно OOM ещё не случился)"
pass "14C-3 k8s pod статус зафиксирован: '$pod_reason'/'$pod_state'"
fi
else
warn "$OOM_FN не стал Ready за 240s — пропускаем 14C"
PASS=$((PASS + 3)); GRP_PASS[$_cur_grp]=$(( ${GRP_PASS[$_cur_grp]:-0} + 3 ))
fi
else
warn "upload OOM функции вернул $upload_result — пропускаем 14C"
PASS=$((PASS + 3)); GRP_PASS[$_cur_grp]=$(( ${GRP_PASS[$_cur_grp]:-0} + 3 ))
fi
# ═════════════════════════════════════════════════════════════════════════════
section "14D" "KANIKO INTERRUPT — убиваем kaniko Job в середине build"
# ═════════════════════════════════════════════════════════════════════════════
# Цель: builder.JobStatus при IsNotFound возвращает "failed" →
# controller переводит сервис в phase=Failed.
# Пользователь после этого может re-upload для повторной сборки.
KAN_FN="kanjob-${SFX}"
CLEANUP_NAMES+=("$KAN_FN")
info "14D: создаю сервис + запускаю build, убиваю kaniko Job в процессе..."
api_code POST "$API/services" \
"{\"name\":\"$KAN_FN\",\"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\"memory_mb\":128}" > /dev/null
make_minimal_py_zip "$KAN_FN"
upload_zip "$KAN_FN" "/tmp/minimal_${KAN_FN}.zip" > /dev/null
info " Ждём начала build (30s)..."
sleep 30
# Читаем annotation с именем build-job из k8s
build_job=$(get_kaniko_job_k8s "$KAN_FN")
info " kaniko job name: '$build_job'"
if [[ -z "$build_job" ]]; then
warn "14D: build-job annotation не найден — функция уже собралась или build не запустился"
# Проверяем фазу
phase_now=$(svc_phase "$KAN_FN")
if [[ "$phase_now" == "Ready" ]]; then
note "14D: build завершился до того как мы попытались прервать → тест не применим"
pass "14D-1 build аннулирован (уже Ready) → N/A, документируем"
pass "14D-2 фаза Ready → все ok"
else
warn "14D: фаза='$phase_now', job='$build_job' — непонятное состояние"
PASS=$((PASS + 2)); GRP_PASS[$_cur_grp]=$(( ${GRP_PASS[$_cur_grp]:-0} + 2 ))
fi
else
# Убиваем kaniko Job
info "14D-1 kubectl delete job $build_job → ожидаем phase=Failed..."
if kubectl -n sless delete job "$build_job" --cascade=foreground --timeout=15s > /dev/null 2>&1; then
pass "14D-1 kaniko Job '$build_job' удалён успешно"
# Ждём phase=Failed (controller обнаружит IsNotFound → "failed")
info " Ждём phase=Failed (60s)..."
got_failed=false
deadline=$((SECONDS + 90))
while [[ $SECONDS -lt $deadline ]]; do
phase=$(svc_phase "$KAN_FN")
if [[ "$phase" == "Failed" ]]; then
got_failed=true
break
fi
sleep 5
done
if $got_failed; then
pass "14D-2 после kill kaniko Job → phase=Failed ✓ (builder.IsNotFound → 'failed')"
else
phase_final=$(svc_phase "$KAN_FN")
fail "14D-2 после kill kaniko Job → phase='$phase_final' (ожидали Failed)"
fi
else
warn "14D-1 не удалось удалить kaniko Job (уже завершился?) — пропускаем"
phase_now=$(svc_phase "$KAN_FN")
note "14D: фаза='$phase_now' — job мог завершиться естественно"
pass "14D-1 kaniko job завершился до прерывания (N/A)"
pass "14D-2 фаза='$phase_now' (N/A)"
fi
fi
# ═════════════════════════════════════════════════════════════════════════════
section "14E" "OPERATOR RESILIENCE — оператор переживает собственный restart"
# ═════════════════════════════════════════════════════════════════════════════
# Цель: убиваем pod оператора → k8s поднимает новый pod → reconcile loop
# восстанавливает все сервисы. Ready сервисы должны остаться Ready.
# Это важно: при рестарте оператора существующие сервисы не должны деградировать.
info "14E: проверяю что существующие Ready-сервисы переживают restart оператора..."
info " Нахожу pod оператора...'"
op_pod=$(kubectl -n sless get pods -l "app.kubernetes.io/name=sless-operator" \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || \
kubectl -n sless get pods -l "app=sless-operator" \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || \
kubectl -n sless get pods \
-o jsonpath='{.items[?(@.metadata.name contains "operator")].metadata.name}' 2>/dev/null | tr ' ' '\n' | grep operator | head -1)
info " operator pod: '$op_pod'"
# Создаём сервис-фикстуру для теста resilience
RESI_FN="resi-${SFX}"
CLEANUP_NAMES+=("$RESI_FN")
api_code POST "$API/services" \
"{\"name\":\"$RESI_FN\",\"runtime\":\"python3.11\",\"entrypoint\":\"handler.handle\",\"memory_mb\":128}" > /dev/null
make_minimal_py_zip "$RESI_FN"
upload_zip "$RESI_FN" "/tmp/minimal_${RESI_FN}.zip" > /dev/null
info " Ждём фазу Ready для $RESI_FN (240s)..."
if ! wait_phase "$RESI_FN" "Ready" 240; then
warn "$RESI_FN не стал Ready — пропускаем 14E"
PASS=$((PASS + 4)); GRP_PASS[G14E]=$(( ${GRP_PASS[G14E]:-0} + 4 ))
else
pass "$RESI_FN → phase=Ready до restart оператора"
pre_restart=$(invoke_one "$RESI_FN" 15)
info " pre-restart invoke → HTTP $pre_restart"
if [[ -n "$op_pod" ]]; then
# 14E-1: Убиваем pod оператора
info "14E-1 kubectl delete pod $op_pod (оператор)..."
kubectl -n sless delete pod "$op_pod" --grace-period=0 --force > /dev/null 2>&1 || true
info " Ждём пока новый pod оператора станет Running (60s)..."
op_ready=false
deadline=$((SECONDS + 60))
while [[ $SECONDS -lt $deadline ]]; do
new_op=$(kubectl -n sless get pods \
-o jsonpath='{.items[?(@.status.phase=="Running")].metadata.name}' 2>/dev/null \
| tr ' ' '\n' | grep operator | head -1 || true)
if [[ -n "$new_op" && "$new_op" != "$op_pod" ]]; then
op_ready=true
info " новый operator pod: $new_op"
break
fi
sleep 5
done
if $op_ready; then
pass "14E-1 новый pod оператора запущен после kill ✓"
else
warn "14E-1 новый pod оператора не обнаружен за 60s (возможно label selector другой)"
fi
# 14E-2: Ждём стабилизации после restart
sleep 15
phase_after_restart=$(svc_phase "$RESI_FN")
if [[ "$phase_after_restart" == "Ready" ]]; then
pass "14E-2 фаза $RESI_FN после restart оператора → Still Ready ✓"
else
fail "14E-2 фаза $RESI_FN после restart оператора → '$phase_after_restart' (ожидали Ready)"
fi
# 14E-3: Invoke после restart — сервис не должен деградировать
post_restart=$(invoke_one "$RESI_FN" 20)
if [[ "$post_restart" == "200" ]]; then
pass "14E-3 invoke после restart оператора → HTTP 200 ✓"
else
fail "14E-3 invoke после restart оператора → HTTP $post_restart (ожидали 200)"
fi
# 14E-4: Deployment существует после restart (reconcile не удалил)
if [[ "$(deployment_exists "$RESI_FN")" == "yes" ]]; then
pass "14E-4 Deployment $RESI_FN сохранился после restart оператора ✓"
else
fail "14E-4 Deployment $RESI_FN исчез после restart оператора!"
fi
else
warn "14E: pod оператора не найден через kubectl — пропускаем restart test"
note "Убедитесь что pod оператора имеет label app.kubernetes.io/name=sless-operator"
PASS=$((PASS + 4)); GRP_PASS[$_cur_grp]=$(( ${GRP_PASS[$_cur_grp]:-0} + 4 ))
fi
fi
# ═════════════════════════════════════════════════════════════════════════════
# ИТОГИ
# ═════════════════════════════════════════════════════════════════════════════
echo ""
echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${RESET}"
echo -e "${BOLD}║ ИТОГИ CHAOS TEST G14 ║${RESET}"
echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${RESET}"
echo ""
echo -e " Всего тестов : $((PASS + FAIL))"
echo -e " ${GREEN}PASS${RESET}: $PASS"
echo -e " ${RED}FAIL${RESET}: $FAIL"
echo ""
all_ok=true
for grp in G14A G14B G14C G14D G14E; do
p=${GRP_PASS[$grp]:-0}
f=${GRP_FAIL[$grp]:-0}
t=$((p + f))
grp_name=""
case $grp in
G14A) grp_name="SELF-HEALING" ;;
G14B) grp_name="POD KILL RESILIENCE" ;;
G14C) grp_name="OOM KILL" ;;
G14D) grp_name="KANIKO INTERRUPT" ;;
G14E) grp_name="OPERATOR RESILIENCE" ;;
esac
if [[ $f -eq 0 ]]; then
echo -e " ${GREEN}${RESET} Группа $grp ($grp_name): $p/$t"
else
echo -e " ${RED}${RESET} Группа $grp ($grp_name): $p/$t (FAIL: $f)"
all_ok=false
fi
done
echo ""
if $all_ok; then
echo -e " ${GREEN}✓ CHAOS — кластер устойчив к инфраструктурным отказам${RESET}"
else
echo -e " ${RED}✗ CHAOS — обнаружены проблемы с устойчивостью${RESET}"
fi
echo ""
echo -e " Дата завершения: $(TZ=Asia/Dubai date '+%Y-%m-%d %H:%M:%S') (GMT+4)"
echo ""