#!/usr/bin/env bash # 2026-03-30 — vm_stress_test.sh (v2 — READ-ONLY) # Автономный READ-ONLY stress/chaos тест для examples/VM. # # ⛔⛔⛔ КРИТИЧЕСКОЕ ПРАВИЛО — ДЛЯ AI-АГЕНТОВ И ЛЮДЕЙ ⛔⛔⛔ # # Этот скрипт НИКОГДА НЕ МОДИФИЦИРУЕТ terraform.tfvars и НИКАКИЕ ДРУГИЕ ФАЙЛЫ. # Все переопределения переменных — ТОЛЬКО через terraform CLI опцию -var. # Файл terraform.tfvars ЧИТАЕТСЯ, но НИКОГДА НЕ ПЕРЕЗАПИСЫВАЕТСЯ. # # ЗАПРЕЩЕНО: # - Редактировать этот скрипт # - Редактировать terraform.tfvars # - Редактировать любые .tf файлы # - Запускать terraform напрямую — только через этот скрипт # # ПРИЧИНА: предыдущая версия скрипта уничтожила terraform.tfvars # через write_tfvars() — потерян JWT-токен api_token. Это НЕДОПУСТИМО. # # ФАЗЫ: # 1 BASELINE — apply с текущим tfvars (packages + nginx + docker) # 2 IDEMPOTENT — повторный plan → "No changes" # 3 PARTIAL_DISABLE — apply с -var install_nginx=false -var install_docker=false # 4 PARTIAL_ENABLE — apply с -var install_nginx=true -var install_docker=true (run_id+1) # 5 REORDER_PACKAGES — apply с -var 'base_packages=["htop","jq"]' (run_id+1) # 6 MANUAL_PURGE — удалить пакеты с VM по SSH → apply (run_id+1) # 7 DESTROY — terraform destroy → VM уходит в suspend # 8 RESURRECT — apply после destroy → VM просыпается # 9 STRESS_CYCLES — N подряд destroy/apply циклов # 10 FINAL_SANITY — проверить доступность VM и пакеты # # ЗАПУСК: # cd ~/terra/sless/examples/VM # bash vm_stress_test.sh 2>&1 | tee /tmp/vm_stress_$(date +%Y%m%d_%H%M).log # # ПАРАМЕТРЫ (env): # STRESS_CYCLES=2 — количество destroy/apply циклов в фазе 9 (default: 2) # SKIP_DESTROY=1 — пропустить фазы 7-9 (для быстрого прогона) # # АНАЛИЗ РЕЗУЛЬТАТОВ: # grep -E '\[(PASS|FAIL|SKIP)\]' /tmp/vm_stress.log # Итоговая сводка печатается в конце лога. # # ТРЕБОВАНИЯ: terraform, ssh, python3 — всё на VM naeel@5.172.178.213 set -uo pipefail # ── CONFIG ──────────────────────────────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" VM_KEY="$SCRIPT_DIR/vm_key" STRESS_CYCLES="${STRESS_CYCLES:-2}" SKIP_DESTROY="${SKIP_DESTROY:-0}" # Читаем текущий run_id из terraform.tfvars (ТОЛЬКО ЧТЕНИЕ, не запись) RUN_ID=$(grep 'install_run_id' terraform.tfvars | grep -oP '\d+' | head -1) # ── СТАТИСТИКА ──────────────────────────────────────────────────────────────── PASS=0; FAIL=0; SKIP=0 PHASE_RESULTS=() START_TIME=$SECONDS # ── ЦВЕТА ───────────────────────────────────────────────────────────────────── GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m' CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' # ── HELPERS ─────────────────────────────────────────────────────────────────── pass() { echo -e " ${GREEN}[PASS]${RESET} $1"; ((PASS++)); } fail() { echo -e " ${RED}[FAIL]${RESET} $1"; ((FAIL++)); } skip() { echo -e " ${YELLOW}[SKIP]${RESET} $1"; ((SKIP++)); } info() { echo -e " ${CYAN}[INFO]${RESET} $1"; } warn() { echo -e " ${YELLOW}[WARN]${RESET} $1"; } phase_header() { local num="$1" name="$2" echo "" echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" echo -e "${BOLD}${CYAN} ФАЗА $num: $name${RESET}" echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" echo -e " ${CYAN}[TIME]${RESET} $(date '+%H:%M:%S')" } phase_result() { local name="$1" result="$2" PHASE_RESULTS+=("$name:$result") echo -e " ${CYAN}[PHASE]${RESET} $name → ${result}" } # ── TERRAFORM HELPERS ───────────────────────────────────────────────────────── # ⛔ НЕ ТРОГАЕМ terraform.tfvars. Переопределения — ТОЛЬКО через -var. # terraform.tfvars подхватывается автоматически (лежит в рабочей директории). # Доп. -var аргументы передаются во все tf_* функции и ПЕРЕОПРЕДЕЛЯЮТ tfvars. # tf_apply: apply с retry при сетевых ошибках. # Аргументы: любые доп. -var (опционально). # Пример: tf_apply -var install_nginx=false -var install_docker=false tf_apply() { local attempt=1 max=3 while [[ $attempt -le $max ]]; do info "terraform apply (попытка $attempt/$max)..." if terraform apply -auto-approve -input=false -no-color "$@" 2>&1 | tee /tmp/vm_tf_apply.log; then return 0 fi if grep -Eiq 'TLS handshake timeout|unexpected EOF|i/o timeout|context deadline|Client\.Timeout' /tmp/vm_tf_apply.log && [[ $attempt -lt $max ]]; then warn "сетевой сбой, retry через $((attempt * 5))s..." sleep $((attempt * 5)) ((attempt++)) continue fi return 1 done return 1 } # tf_destroy: destroy с retry. # Аргументы: любые доп. -var (опционально). tf_destroy() { local attempt=1 max=3 while [[ $attempt -le $max ]]; do info "terraform destroy (попытка $attempt/$max)..." if terraform destroy -auto-approve -input=false -no-color "$@" 2>&1 | tee /tmp/vm_tf_destroy.log; then return 0 fi if grep -Eiq 'TLS handshake timeout|unexpected EOF|i/o timeout|context deadline|Client\.Timeout' /tmp/vm_tf_destroy.log && [[ $attempt -lt $max ]]; then warn "сетевой сбой, retry через $((attempt * 5))s..." sleep $((attempt * 5)) ((attempt++)) continue fi return 1 done return 1 } # tf_plan_no_changes: проверить plan → "No changes". # Аргументы: любые доп. -var (опционально). tf_plan_no_changes() { terraform plan -input=false -no-color "$@" 2>&1 | tee /tmp/vm_tf_plan.log grep -q 'No changes' /tmp/vm_tf_plan.log } # tf_state_count: количество ресурсов в state. tf_state_count() { terraform state list 2>/dev/null | wc -l } # next_run_id: инкрементировать внутренний счётчик RUN_ID и вернуть новое значение. # Используется для -var install_run_id=N чтобы форсировать пересоздание jobs. next_run_id() { RUN_ID=$((RUN_ID + 1)) echo "$RUN_ID" } # ── VM SSH HELPERS ──────────────────────────────────────────────────────────── # get_vm_ip: получить внешний IP VM из terraform output. get_vm_ip() { terraform output -json vm_state 2>/dev/null \ | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('externalConnect',''))" 2>/dev/null } # vm_ssh: выполнить команду на VM. Возвращает exit code команды. vm_ssh() { local ip="$1"; shift ssh -i "$VM_KEY" -o StrictHostKeyChecking=no -o ConnectTimeout=15 \ -o ServerAliveInterval=5 -o ServerAliveCountMax=3 \ "ubuntu@$ip" "$@" } # vm_alive: проверить доступность VM по SSH. vm_alive() { local ip="$1" vm_ssh "$ip" 'echo alive' &>/dev/null } # vm_wait_alive: ждать пока VM ответит по SSH (до timeout_sec). vm_wait_alive() { local ip="$1" timeout_sec="${2:-120}" local deadline=$((SECONDS + timeout_sec)) while [[ $SECONDS -lt $deadline ]]; do if vm_alive "$ip"; then return 0; fi sleep 10 done return 1 } # vm_check_package: проверить что пакет установлен. vm_check_package() { local ip="$1" pkg="$2" vm_ssh "$ip" "dpkg -l $pkg 2>/dev/null | grep -q '^ii'" 2>/dev/null } # vm_check_binary: проверить что бинарник доступен. vm_check_binary() { local ip="$1" bin="$2" vm_ssh "$ip" "command -v $bin" &>/dev/null } # vm_wait_binary: ждать пока бинарник появится на VM (sless_job работает асинхронно). # Нужен потому что sless_job запускает kubernetes Job, который сначала собирает образ, # затем стартует pod, и только потом SSH-устанавливает пакеты на VM — это занимает 1-3 мин. vm_wait_binary() { local ip="$1" bin="$2" timeout_sec="${3:-180}" local deadline=$((SECONDS + timeout_sec)) info "жду появления '$bin' на VM (до ${timeout_sec}s)..." while [[ $SECONDS -lt $deadline ]]; do if vm_check_binary "$ip" "$bin"; then return 0; fi sleep 15 done return 1 } # vm_purge_all: удалить все установленные пакеты с VM. vm_purge_all() { local ip="$1" info "удаляю пакеты с VM $ip..." vm_ssh "$ip" 'sudo systemctl stop nginx docker 2>/dev/null; sudo apt-get purge -y jq python3-pip htop unzip nginx docker-ce docker-ce-cli containerd.io docker-compose-plugin 2>/dev/null; sudo apt-get autoremove -y 2>/dev/null; sudo rm -rf /var/lib/docker /var/lib/containerd; echo purge_done' 2>/dev/null } # ══════════════════════════════════════════════════════════════════════════════ # ФАЗА 1: BASELINE — apply с текущим tfvars (всё включено) # Используем -var install_run_id=N чтобы гарантировать свежий запуск. # terraform.tfvars НЕ ТРОГАЕМ — берём как есть. # ══════════════════════════════════════════════════════════════════════════════ phase_1_baseline() { phase_header 1 "BASELINE — apply с полным набором" local rid rid=$(next_run_id) # Все флаги = true (как в tfvars), но run_id инкрементирован if tf_apply \ -var "install_packages=true" \ -var "install_nginx=true" \ -var "install_docker=true" \ -var "install_run_id=$rid"; then pass "1.1 terraform apply завершился успешно" else fail "1.1 terraform apply упал" phase_result "BASELINE" "FAIL" return 1 fi # Проверить количество ресурсов local count count=$(tf_state_count) if [[ $count -ge 5 ]]; then pass "1.2 state содержит $count ресурсов (ожидали ≥5)" else fail "1.2 state содержит $count ресурсов (ожидали ≥5)" fi # Проверить outputs local out out=$(terraform output -json 2>/dev/null) if echo "$out" | python3 -c "import sys,json; d=json.load(sys.stdin); assert 'ok' in d['install_packages_result']['value'] or 'already' in d['install_packages_result']['value']" 2>/dev/null; then pass "1.3 install_packages_result содержит ok/already_installed" else fail "1.3 install_packages_result не содержит ok" fi if echo "$out" | python3 -c "import sys,json; d=json.load(sys.stdin); v=d['install_nginx_result']['value']; assert 'ok' in v or 'already' in v" 2>/dev/null; then pass "1.4 install_nginx_result содержит ok/already" else fail "1.4 install_nginx_result не содержит ok" fi if echo "$out" | python3 -c "import sys,json; d=json.load(sys.stdin); v=d['install_docker_result']['value']; assert 'ok' in v or 'already' in v" 2>/dev/null; then pass "1.5 install_docker_result содержит ok/already" else fail "1.5 install_docker_result не содержит ok" fi # Проверить VM по SSH local ip ip=$(get_vm_ip) if [[ -n "$ip" ]] && vm_alive "$ip"; then pass "1.6 VM $ip доступна по SSH" else fail "1.6 VM не доступна по SSH (ip=$ip)" fi phase_result "BASELINE" "PASS" } # ══════════════════════════════════════════════════════════════════════════════ # ФАЗА 2: IDEMPOTENT — повторный apply с теми же аргументами → 0 changed # Используем apply (не plan) — это надёжнее: некоторые sless-провайдеры показывают # ложный drift в plan, но apply при этом возвращает 0 changed. Apply — canonical way. # ══════════════════════════════════════════════════════════════════════════════ phase_2_idempotent() { phase_header 2 "IDEMPOTENT — повторный apply без изменений" # Тот же run_id что применялся в фазе 1 → apply должен вернуть 0 changed # sless_job — эфемерный ресурс, каждый apply пересоздаёт job-ресурсы (add+destroy). # Идемпотентность = "0 changed" (ноль in-place изменений), а не "0 add/destroy". # VM и vApp не должны изменяться никогда. if tf_apply \ -var "install_packages=true" \ -var "install_nginx=true" \ -var "install_docker=true" \ -var "install_run_id=$RUN_ID"; then if grep -q ', 0 changed,' /tmp/vm_tf_apply.log; then pass "2.1 повторный apply → 0 changed (sless_job пересоздан — ожидаемо)" grep -E 'Resources:' /tmp/vm_tf_apply.log | tail -1 | while read -r line; do info " $line" done else fail "2.1 повторный apply изменил persistent ресурсы (ожидали 0 changed)" grep -E 'added|changed|destroyed' /tmp/vm_tf_apply.log | tail -3 | while read -r line; do info " $line" done fi else fail "2.1 повторный apply упал" fi phase_result "IDEMPOTENT" "PASS" } # ══════════════════════════════════════════════════════════════════════════════ # ФАЗА 3: PARTIAL_DISABLE — выключить nginx + docker # Используем -var install_nginx=false -var install_docker=false # terraform.tfvars по-прежнему НЕ ТРОГАЕМ. # ══════════════════════════════════════════════════════════════════════════════ phase_3_partial_disable() { phase_header 3 "PARTIAL_DISABLE — выключить nginx + docker" if tf_apply \ -var "install_packages=true" \ -var "install_nginx=false" \ -var "install_docker=false" \ -var "install_run_id=$RUN_ID"; then pass "3.1 apply с partial disable завершился" else fail "3.1 apply с partial disable упал" phase_result "PARTIAL_DISABLE" "FAIL" return 1 fi # Проверить что nginx и docker job пропали из state local state_list state_list=$(terraform state list 2>/dev/null) if echo "$state_list" | grep -q 'install_nginx'; then fail "3.2 install_nginx всё ещё в state" else pass "3.2 install_nginx убран из state" fi if echo "$state_list" | grep -q 'install_docker'; then fail "3.3 install_docker всё ещё в state" else pass "3.3 install_docker убран из state" fi if echo "$state_list" | grep -q 'install_packages'; then pass "3.4 install_packages остался в state" else fail "3.4 install_packages пропал из state" fi # Проверить outputs local nginx_out docker_out nginx_out=$(terraform output -raw install_nginx_result 2>/dev/null) docker_out=$(terraform output -raw install_docker_result 2>/dev/null) if [[ "$nginx_out" == "skipped" ]]; then pass "3.5 install_nginx_result = skipped" else fail "3.5 install_nginx_result = '$nginx_out' (ожидали skipped)" fi if [[ "$docker_out" == "skipped" ]]; then pass "3.6 install_docker_result = skipped" else fail "3.6 install_docker_result = '$docker_out' (ожидали skipped)" fi phase_result "PARTIAL_DISABLE" "PASS" } # ══════════════════════════════════════════════════════════════════════════════ # ФАЗА 4: PARTIAL_ENABLE — включить обратно всё # Инкрементируем run_id чтобы jobs пересоздались. # ══════════════════════════════════════════════════════════════════════════════ phase_4_partial_enable() { phase_header 4 "PARTIAL_ENABLE — включить обратно всё" local rid rid=$(next_run_id) if tf_apply \ -var "install_packages=true" \ -var "install_nginx=true" \ -var "install_docker=true" \ -var "install_run_id=$rid"; then pass "4.1 apply с полным набором завершился" else fail "4.1 apply с полным набором упал" phase_result "PARTIAL_ENABLE" "FAIL" return 1 fi local count count=$(tf_state_count) if [[ $count -ge 5 ]]; then pass "4.2 state содержит $count ресурсов (ожидали ≥5)" else fail "4.2 state содержит $count ресурсов (ожидали ≥5)" fi phase_result "PARTIAL_ENABLE" "PASS" } # ══════════════════════════════════════════════════════════════════════════════ # ФАЗА 5: REORDER_PACKAGES — изменить порядок и состав base_packages # Через -var 'base_packages=["htop","jq"]' — terraform.tfvars не трогаем. # ══════════════════════════════════════════════════════════════════════════════ phase_5_reorder() { phase_header 5 "REORDER_PACKAGES — изменить порядок и состав пакетов" local rid rid=$(next_run_id) # Сокращённый набор пакетов через -var if tf_apply \ -var "install_packages=true" \ -var "install_nginx=true" \ -var "install_docker=true" \ -var "install_run_id=$rid" \ -var 'base_packages=["htop","jq"]'; then pass "5.1 apply с изменённым набором пакетов завершился" else fail "5.1 apply с изменённым набором пакетов упал" phase_result "REORDER_PACKAGES" "FAIL" return 1 fi # Проверить output local pkg_out pkg_out=$(terraform output -raw install_packages_result 2>/dev/null) if echo "$pkg_out" | grep -qE '"status"|ok|already'; then pass "5.2 install_packages вернул ожидаемый результат" else fail "5.2 install_packages output неожиданный: $pkg_out" fi # Вернуть полный набор пакетов rid=$(next_run_id) tf_apply \ -var "install_packages=true" \ -var "install_nginx=true" \ -var "install_docker=true" \ -var "install_run_id=$rid" \ || warn "5.3 восстановление полного набора не удалось" phase_result "REORDER_PACKAGES" "PASS" } # ══════════════════════════════════════════════════════════════════════════════ # ФАЗА 6: MANUAL_PURGE — удалить пакеты с VM вручную, заново установить # ══════════════════════════════════════════════════════════════════════════════ phase_6_manual_purge() { phase_header 6 "MANUAL_PURGE — удалить пакеты с VM, заново установить" local ip ip=$(get_vm_ip) if [[ -z "$ip" ]]; then fail "6.0 не удалось получить IP VM" phase_result "MANUAL_PURGE" "FAIL" return 1 fi # Удалить всё с VM vm_purge_all "$ip" # Проверить что пакеты действительно удалены if vm_check_binary "$ip" "docker"; then fail "6.1 docker всё ещё на VM после purge" else pass "6.1 docker удалён с VM" fi if vm_check_binary "$ip" "nginx"; then fail "6.2 nginx всё ещё на VM после purge" else pass "6.2 nginx удалён с VM" fi if vm_check_binary "$ip" "jq"; then fail "6.3 jq всё ещё на VM после purge" else pass "6.3 jq удалён с VM" fi # Пересоздать jobs (bump run_id) local rid rid=$(next_run_id) info "запускаю переустановку через sless_job..." if tf_apply \ -var "install_packages=true" \ -var "install_nginx=true" \ -var "install_docker=true" \ -var "install_run_id=$rid"; then pass "6.4 apply после purge завершился" else fail "6.4 apply после purge упал" phase_result "MANUAL_PURGE" "FAIL" return 1 fi # Ждать пока sless_job отработает: docker самый долгий (k8s Job + образ + apt-install ~200MB). # docker-ce требует до 5 минут на первой установке — ставим 360s. if vm_wait_binary "$ip" "docker" 360; then pass "6.5 docker установлен заново" else fail "6.5 docker НЕ установлен после re-apply (таймаут 360s)" fi if vm_wait_binary "$ip" "nginx" 240; then pass "6.6 nginx установлен заново" else fail "6.6 nginx НЕ установлен после re-apply (таймаут 240s)" fi if vm_check_binary "$ip" "jq"; then pass "6.7 jq установлен заново" else fail "6.7 jq НЕ установлен после re-apply" fi phase_result "MANUAL_PURGE" "PASS" } # ══════════════════════════════════════════════════════════════════════════════ # ФАЗА 7: DESTROY — terraform destroy, VM уходит в suspend # ══════════════════════════════════════════════════════════════════════════════ phase_7_destroy() { phase_header 7 "DESTROY — terraform destroy → VM в suspend" local ip ip=$(get_vm_ip) if tf_destroy; then pass "7.1 terraform destroy завершился" else fail "7.1 terraform destroy упал" phase_result "DESTROY" "FAIL" return 1 fi # State должен быть пуст local count count=$(tf_state_count) if [[ $count -eq 0 ]]; then pass "7.2 state пуст ($count ресурсов)" else fail "7.2 state не пуст ($count ресурсов)" fi # VM не должна отвечать по SSH if [[ -n "$ip" ]]; then info "проверяю что VM $ip недоступна..." if vm_alive "$ip"; then fail "7.3 VM $ip всё ещё отвечает по SSH после destroy" else pass "7.3 VM $ip не отвечает по SSH (suspend подтверждён)" fi else skip "7.3 IP VM неизвестен, пропускаю SSH-проверку" fi phase_result "DESTROY" "PASS" } # ══════════════════════════════════════════════════════════════════════════════ # ФАЗА 8: RESURRECT — apply после destroy, VM просыпается # Используем текущий run_id — terraform.tfvars НЕ ТРОГАЕМ. # ══════════════════════════════════════════════════════════════════════════════ phase_8_resurrect() { phase_header 8 "RESURRECT — apply после destroy" local rid rid=$(next_run_id) if tf_apply \ -var "install_packages=true" \ -var "install_nginx=true" \ -var "install_docker=true" \ -var "install_run_id=$rid"; then pass "8.1 apply после destroy завершился" else fail "8.1 apply после destroy упал" phase_result "RESURRECT" "FAIL" return 1 fi local count count=$(tf_state_count) if [[ $count -ge 5 ]]; then pass "8.2 state содержит $count ресурсов" else fail "8.2 state содержит $count ресурсов (ожидали ≥5)" fi # Проверить VM local ip ip=$(get_vm_ip) if [[ -n "$ip" ]] && vm_alive "$ip"; then pass "8.3 VM $ip доступна по SSH после resurrect" else # VM может ещё просыпаться — ждём info "VM не отвечает, жду до 120s..." if vm_wait_alive "$ip" 120; then pass "8.3 VM $ip доступна по SSH после ожидания" else fail "8.3 VM $ip не доступна по SSH через 120s" fi fi # Проверить пакеты if [[ -n "$ip" ]] && vm_alive "$ip"; then if vm_check_binary "$ip" "jq"; then pass "8.4 jq установлен после resurrect" else fail "8.4 jq НЕ установлен после resurrect" fi if vm_check_binary "$ip" "nginx"; then pass "8.5 nginx установлен после resurrect" else fail "8.5 nginx НЕ установлен после resurrect" fi if vm_check_binary "$ip" "docker"; then pass "8.6 docker установлен после resurrect" else fail "8.6 docker НЕ установлен после resurrect" fi fi phase_result "RESURRECT" "PASS" } # ══════════════════════════════════════════════════════════════════════════════ # ФАЗА 9: STRESS_CYCLES — N destroy/apply циклов подряд # ══════════════════════════════════════════════════════════════════════════════ phase_9_stress() { phase_header 9 "STRESS_CYCLES — $STRESS_CYCLES циклов destroy/apply" local i rid for i in $(seq 1 "$STRESS_CYCLES"); do info "── цикл $i/$STRESS_CYCLES ──" info "[$i] destroy..." if tf_destroy; then pass "9.${i}a destroy цикл $i" else fail "9.${i}a destroy цикл $i упал" # Попробовать восстановиться rid=$(next_run_id) tf_apply \ -var "install_packages=true" \ -var "install_nginx=true" \ -var "install_docker=true" \ -var "install_run_id=$rid" || true continue fi rid=$(next_run_id) info "[$i] apply (run_id=$rid)..." if tf_apply \ -var "install_packages=true" \ -var "install_nginx=true" \ -var "install_docker=true" \ -var "install_run_id=$rid"; then pass "9.${i}b apply цикл $i" else fail "9.${i}b apply цикл $i упал" continue fi done phase_result "STRESS_CYCLES" "PASS" } # ══════════════════════════════════════════════════════════════════════════════ # ФАЗА 10: FINAL_SANITY — финальная проверка состояния # ══════════════════════════════════════════════════════════════════════════════ phase_10_final() { phase_header 10 "FINAL_SANITY — финальная проверка" # Убедиться что state на месте local count count=$(tf_state_count) if [[ $count -ge 5 ]]; then pass "10.1 state содержит $count ресурсов" else fail "10.1 state содержит $count ресурсов (ожидали ≥5)" # Попробовать восстановить (через -var, БЕЗ изменения файлов) local rid rid=$(next_run_id) info "пытаюсь восстановить baseline..." tf_apply \ -var "install_packages=true" \ -var "install_nginx=true" \ -var "install_docker=true" \ -var "install_run_id=$rid" || warn "восстановление не удалось" fi # Idempotency финально: повторный apply → 0 changed. # sless_job пересоздаются (add+destroy) — это нормально для job-ресурса. # Проверяем только "0 changed" — VM и vApp не должны изменяться. if tf_apply \ -var "install_packages=true" \ -var "install_nginx=true" \ -var "install_docker=true" \ -var "install_run_id=$RUN_ID"; then if grep -q ', 0 changed,' /tmp/vm_tf_apply.log; then pass "10.2 финальный apply → 0 changed (sless_job пересоздан — ожидаемо)" grep -E 'Resources:' /tmp/vm_tf_apply.log | tail -1 | while read -r line; do info " $line" done else fail "10.2 финальный apply изменил persistent ресурсы (ожидали 0 changed)" grep -E 'added|changed|destroyed' /tmp/vm_tf_apply.log | tail -3 | while read -r line; do info " $line" done fi else fail "10.2 финальный apply упал" fi # VM доступна local ip ip=$(get_vm_ip) if [[ -n "$ip" ]] && vm_alive "$ip"; then pass "10.3 VM $ip доступна по SSH" # Полная проверка пакетов for pkg in jq htop unzip; do if vm_check_package "$ip" "$pkg"; then pass "10.4 пакет $pkg установлен" else fail "10.4 пакет $pkg НЕ установлен" fi done if vm_check_binary "$ip" "nginx"; then pass "10.5 nginx работает" else fail "10.5 nginx НЕ найден" fi if vm_check_binary "$ip" "docker"; then pass "10.6 docker работает" else fail "10.6 docker НЕ найден" fi # nginx service активен (не просто бинарник, а именно демон) if vm_ssh "$ip" "systemctl is-active nginx 2>/dev/null" 2>/dev/null | grep -q '^active$'; then pass "10.7 nginx service активен (systemctl)" else fail "10.7 nginx service не активен" fi # docker daemon активен if vm_ssh "$ip" "systemctl is-active docker 2>/dev/null" 2>/dev/null | grep -q '^active$'; then pass "10.8 docker daemon активен (systemctl)" else fail "10.8 docker daemon не активен" fi # HTTP probe: nginx отвечает на localhost:80 local http_code http_code=$(vm_ssh "$ip" "curl -sS -o /dev/null -w '%{http_code}' http://localhost 2>/dev/null" 2>/dev/null) if [[ "$http_code" == "200" ]]; then pass "10.9 nginx отвечает HTTP 200" else fail "10.9 nginx не отвечает HTTP 200 (code='$http_code')" fi # python3 доступен local py_ver py_ver=$(vm_ssh "$ip" "python3 --version 2>&1" 2>/dev/null) if echo "$py_ver" | grep -q 'Python 3'; then pass "10.10 python3 доступен ($py_ver)" else fail "10.10 python3 недоступен" fi # docker smoke: запустить контейнер и проверить вывод if vm_ssh "$ip" "docker run --rm hello-world 2>&1 | grep -q 'Hello from Docker'" 2>/dev/null; then pass "10.11 docker run hello-world → успешно" else fail "10.11 docker run hello-world → не прошёл" fi # disk space: убедиться что на VM есть место (>500MB free) local free_mb free_mb=$(vm_ssh "$ip" "df -m / 2>/dev/null | awk 'NR==2{print \$4}'" 2>/dev/null) if [[ -n "$free_mb" && "$free_mb" -gt 500 ]]; then pass "10.12 свободное место на / = ${free_mb}MB (>500MB)" else fail "10.12 мало места на / = ${free_mb}MB (ожидали >500MB)" fi else fail "10.3 VM не доступна по SSH" fi phase_result "FINAL_SANITY" "PASS" } # ══════════════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════════════ echo -e "${BOLD}${CYAN}" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ VM STRESS TEST v2 (READ-ONLY) — examples/VM ║" echo "║ $(date '+%Y-%m-%d %H:%M:%S') ║" echo "║ Начальный run_id: $RUN_ID ║" echo "║ Циклов stress: $STRESS_CYCLES ║" echo "║ terraform.tfvars НЕ МОДИФИЦИРУЕТСЯ ║" echo "╚═══════════════════════════════════════════════════════════════╝" echo -e "${RESET}" # Проверить что мы в правильной директории if [[ ! -f "terraform.tfvars" ]]; then echo -e "${RED}ОШИБКА: terraform.tfvars не найден. Запускайте из examples/VM/${RESET}" exit 1 fi if [[ ! -f "$VM_KEY" ]]; then echo -e "${RED}ОШИБКА: $VM_KEY не найден${RESET}" exit 1 fi # ⛔ ПРОВЕРКА ЦЕЛОСТНОСТИ: terraform.tfvars НЕ ДОЛЖЕН БЫТЬ МОДИФИЦИРОВАН # Сохраняем md5 до запуска и проверяем после каждой фазы TFVARS_MD5=$(md5sum terraform.tfvars | awk '{print $1}') info "md5 terraform.tfvars = $TFVARS_MD5 (будет проверяться после каждой фазы)" check_tfvars_integrity() { local current_md5 current_md5=$(md5sum terraform.tfvars | awk '{print $1}') if [[ "$current_md5" != "$TFVARS_MD5" ]]; then echo -e "${RED}⛔⛔⛔ КРИТИЧЕСКАЯ ОШИБКА: terraform.tfvars был изменён! ⛔⛔⛔${RESET}" echo -e "${RED}Ожидали md5: $TFVARS_MD5${RESET}" echo -e "${RED}Текущий md5: $current_md5${RESET}" echo -e "${RED}АВАРИЙНАЯ ОСТАНОВКА ТЕСТА${RESET}" exit 99 fi } # Запуск фаз с проверкой целостности после каждой phase_1_baseline; check_tfvars_integrity phase_2_idempotent; check_tfvars_integrity phase_3_partial_disable; check_tfvars_integrity phase_4_partial_enable; check_tfvars_integrity phase_5_reorder; check_tfvars_integrity phase_6_manual_purge; check_tfvars_integrity if [[ "$SKIP_DESTROY" == "1" ]]; then skip "фазы 7-9 пропущены (SKIP_DESTROY=1)" PHASE_RESULTS+=("DESTROY:SKIP" "RESURRECT:SKIP" "STRESS_CYCLES:SKIP") else phase_7_destroy; check_tfvars_integrity phase_8_resurrect; check_tfvars_integrity phase_9_stress; check_tfvars_integrity fi phase_10_final; check_tfvars_integrity # ── ИТОГОВАЯ СВОДКА ────────────────────────────────────────────────────────── ELAPSED=$((SECONDS - START_TIME)) ELAPSED_MIN=$((ELAPSED / 60)) ELAPSED_SEC=$((ELAPSED % 60)) echo "" echo -e "${BOLD}${CYAN}╔═══════════════════════════════════════════════════════════════╗${RESET}" echo -e "${BOLD}${CYAN}║ ИТОГОВАЯ СВОДКА ║${RESET}" echo -e "${BOLD}${CYAN}╠═══════════════════════════════════════════════════════════════╣${RESET}" echo -e "${BOLD} Время: ${ELAPSED_MIN}m ${ELAPSED_SEC}s${RESET}" echo -e "${BOLD} ${GREEN}PASS: $PASS${RESET} ${RED}FAIL: $FAIL${RESET} ${YELLOW}SKIP: $SKIP${RESET}" echo -e "${BOLD} Финальный run_id: $RUN_ID${RESET}" echo "" echo -e "${BOLD} Фазы:${RESET}" for pr in "${PHASE_RESULTS[@]}"; do name="${pr%%:*}" result="${pr##*:}" case "$result" in PASS) echo -e " ${GREEN}✓${RESET} $name" ;; FAIL) echo -e " ${RED}✗${RESET} $name" ;; SKIP) echo -e " ${YELLOW}○${RESET} $name" ;; esac done echo -e "${BOLD}${CYAN}╚═══════════════════════════════════════════════════════════════╝${RESET}" # Финальная проверка целостности tfvars check_tfvars_integrity info "terraform.tfvars НЕ БЫЛ ИЗМЕНЁН (md5 совпадает)" # Exit code: 0 если все PASS, 1 если есть FAIL if [[ $FAIL -gt 0 ]]; then echo -e "\n${RED}РЕЗУЛЬТАТ: FAIL ($FAIL ошибок)${RESET}" exit 1 else echo -e "\n${GREEN}РЕЗУЛЬТАТ: ALL PASS${RESET}" exit 0 fi