- 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 — полная документация сессии
694 lines
34 KiB
Bash
694 lines
34 KiB
Bash
#!/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 ""
|