diff --git a/doc/decisions/log.md b/doc/decisions/log.md index b8ed458..cc6ebd5 100644 --- a/doc/decisions/log.md +++ b/doc/decisions/log.md @@ -2,6 +2,70 @@ --- +## 2026-03-30 — Переход на READ-ONLY подход в тестах (v2) + +### Решение +Полностью переписан `vm_stress_test.sh`. Убраны все функции записи в файлы +(`write_tfvars`, `backup_tfvars`, `restore_tfvars`). Переопределения переменных +теперь через `-var` в terraform CLI. Добавлена проверка md5sum terraform.tfvars. + +### Почему +Функция `write_tfvars()` в v1 уничтожила `terraform.tfvars`, потеряв JWT-токен +`api_token`. Пайплайн `grep | cut | xargs | sed` не смог корректно обработать +JWT строку длиной 1200+ символов. Восстановление потребовало ручного вмешательства. + +### Ключевые принципы v2: +- Скрипт **НИКОГДА** не пишет в файлы (read-only) +- Все переопределения — через `terraform apply -var "key=value"` +- md5sum проверка после каждой фазы, аварийный стоп при изменении +- `terraform.tfvars` содержит секреты (api_token) → нельзя трогать + +--- + +## 2026-03-30 — Автономный тестовый фреймворк для VM + +### Решение +Внедрить bash-скрипт `vm_stress_test.sh` как основной инструмент для долгого, самовосстанавливающегося тестирования примера `examples/VM`. + +### Почему +- Предыдущие ручные тесты подтвердили корректность логики, но для выявления редких race conditions и обеспечения преемственности между разными сессиями агентов нужен воспроизводимый сценарий. +- Скрипт инкапсулирует все "знания" о VM (IP, ключи, логика очистки `apt`), позволяя любому агенту запустить тест одной командой. +- Использование `timeout` на уровне команд `terraform` и `ssh` внутри скрипта предотвращает зависание автоматизации. + +## 2026-03-29 — Матрица тестов VM example подтверждена прогоном + +### Решение + +Оставить текущую модель тестирования [examples/VM](examples/VM) как комбинацию из ручного cleanup, destroy/apply цикла, частичного отключения job-ресурсов и короткого stress loop. + +### Почему + +- Эта матрица проверяет и lifecycle VM, и идемпотентность job-ресурсов, и реакцию на изменение количества/порядка установок. +- Отдельный destroy/apply прогон подтвердил suspend/wake поведение без необходимости писать новый тестовый фреймворк. +- Stress loop из двух циклов дал полезную нагрузку без чрезмерного времени прогона. + +## 2026-03-29 — Матрица тестов для VM example + +### Решение + +Для проверки поведения [examples/VM](examples/VM) использовать не один прогон, а набор сценариев: + +1. обычный `apply` как базовый контроль; +2. удаление всего установленного ПО внутри ВМ перед `destroy`; +3. `destroy` с проверкой перехода ВМ в `suspend`; +4. повторный `apply` с проверкой wake-up и повторной установки; +5. изменение количества и порядка установок; +6. стресс-прогоны с несколькими повторениями. + +### Почему так + +- Один проход не показывает идемпотентность и не ловит проблемы порядка ресурсов. +- Сценарий с `destroy` проверяет, что инфраструктура не удаляет ВМ физически, а переводит её в `suspend`. +- Повторный `apply` после `suspend` проверяет восстановление состояния без ручного вмешательства. +- Перестановки и изменение количества установок нужны, чтобы проверить устойчивость к дрейфу и к разным графам зависимостей. + +--- + ## 2026-03-21 — Оценка трудозатрат на проект | Компонент | Оценка | diff --git a/doc/progress.md b/doc/progress.md index aa9e3d1..81cc12f 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -1,9 +1,95 @@ # Прогресс разработки -Последнее обновление: 2026-03-23 +Последнее обновление: 2026-03-30 --- +## 2026-03-30 — Автоматизация VM stress-тестирования + +### v2 (READ-ONLY) — переписан после инцидента с потерей tfvars + +**Инцидент:** v1 скрипта содержал `write_tfvars()` которая перезаписывала `terraform.tfvars`. +Функция использовала `grep | cut | xargs | sed` для извлечения JWT-токена api_token. +Пайплайн не справился с длинным JWT (1200+ символов) и токен был потерян. +Это сломало весь terraform и потребовало ручного восстановления. + +**Решение (v2):** +- Полностью убраны `write_tfvars()`, `backup_tfvars()`, `restore_tfvars()`, `ensure_baseline()` +- Все переопределения переменных — через `-var` в terraform CLI +- Файл `terraform.tfvars` НИКОГДА не модифицируется +- Добавлена проверка md5sum terraform.tfvars после каждой фазы +- Если файл изменился — АВАРИЙНАЯ ОСТАНОВКА (exit 99) + +### Что сделано +- Переписан скрипт [examples/VM/vm_stress_test.sh](examples/VM/vm_stress_test.sh) (v2, 847 строк, 10 фаз) +- Создана инструкция [examples/VM/VM_TEST_README.md](examples/VM/VM_TEST_README.md) +- Старая версия сохранена как `.vm_stress_test.sh.OLD` + +### Сценарии (10 фаз): +1. **Baseline**: apply с полным набором (packages+nginx+docker) +2. **Idempotent**: plan → "No changes" +3. **Partial Disable**: выключить nginx+docker через `-var` +4. **Partial Enable**: включить обратно +5. **Reorder Packages**: изменить base_packages через `-var` +6. **Manual Purge**: удалить пакеты с VM по SSH → переустановить +7. **Destroy**: terraform destroy → VM suspend +8. **Resurrect**: apply после destroy +9. **Stress Cycles**: N циклов destroy/apply +10. **Final Sanity**: проверка VM + пакеты + plan + +### Текущий статус +- Скрипт проверен на синтаксис (`bash -n`): OK +- Ожидает запуска первой итерации + +--- + +## 2026-03-29 — VM stress/chaos matrix: результаты + +### Что прогнали + +1. Ручной cleanup на целевой VM: удаление `jq`, `python3-pip`, `htop`, `unzip`, `nginx`, `docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-compose-plugin`. +2. `terraform destroy` на [examples/VM](examples/VM). +3. Проверка SSH-доступа после destroy. +4. `terraform apply` после destroy с восстановлением VM и jobs. +5. Повторный `terraform apply` без изменений для проверки идемпотентности. +6. Частичный сценарий с изменением количества ресурсов: `install_nginx=false`, `install_docker=false`, `base_packages=["htop", "jq"]`, `install_run_id=7`. +7. Возврат к полной матрице с другим порядком пакетов: `base_packages=["unzip", "python3-pip", "jq", "htop"]`, `install_run_id=8`. +8. Stress loop из 2 подряд идущих циклов `destroy -> apply`. + +### Результаты + +- Ручной cleanup на VM прошёл: пакеты и бинарники `docker`/`nginx` исчезли. +- `terraform destroy` завершился успешно и вывел `Destroy complete! Resources: 5 destroyed.` +- SSH после destroy не поднялся и ушёл в `Connection timed out`, что соответствует suspend-поведению. +- `terraform apply` после destroy восстановил `vApp + VM + 3 job` и вернул прежние IDs ресурсов. +- Повторный `apply` без изменений дал `No changes`. +- Частичный сценарий с двумя jobs (`install_packages` only) прошёл: лишние job-ресурсы были уничтожены, `install_packages` пересоздан. +- Полная матрица с перестановкой пакетов прошла: `install_packages`, `install_nginx`, `install_docker` восстановились. +- Stress loop из 2 циклов `destroy -> apply` завершился без ошибок. + +### Наблюдения + +- `install_packages_result` отражает уже установленные пакеты на VM; после частичного сценария он вернул только текущий состав списка из `base_packages`. +- `install_nginx_result` и `install_docker_result` при повторном применении в полном состоянии показывают `already_installed`, что подтверждает идемпотентность джобов. +- IDs `vapp_id` и `vm_id` остались прежними после destroy/apply, что согласуется с adopt/suspend моделью. + +## 2026-03-29 — VM stress/chaos matrix: destroy, suspend, reapply, reorder + +### Что планируется + +- Прогнать серию разных тестов на [examples/VM](examples/VM) через `terraform` на удалённой VM. +- Проверить сценарий с удалением всего установленного ПО внутри ВМ, затем `destroy`, после чего убедиться, что ВМ уходит в `suspend`, а не удаляется. +- Проверить `apply` после `destroy`: ВМ должна проснуться, а приложения должны установиться заново. +- Прогнать вариации порядка и количества установок, чтобы увидеть поведение при перестановках ресурсов и изменении состава. +- Отдельно запустить стресс-прогоны и документировать все результаты, включая ошибки и нестабильности. + +### Что будет фиксироваться + +- Команды и их итоговый статус. +- Любые расхождения между планом и фактическим состоянием ВМ. +- Ошибки `terraform`, `ssh` и установки пакетов. +- Поведение suspend/resume и повторной установки после `apply`. + ## 2026-03-28 — Handoff: функции-джобы для установки ПО в ВМ (курс на PostgreSQL) ### Контекст и цель @@ -1417,60 +1503,3 @@ G15 перезапущен → **21/21 PASS ✅** ### Версия оператора `v0.1.51` — задеплоен, работает - ---- - -## Шаг 6: VM provisioning через sless_job (2026-03-29) - -### Цель -Установить пакеты, nginx и docker на свежую Nubes VM через sless_job. - -### Что реализовано -- `examples/VM/sless.tf` — три `sless_job` ресурса с `depends_on` цепочкой: - - `vm-install-packages` → jq, python3-pip, htop, unzip - - `vm-install-nginx` → nginx (depends_on install-packages) - - `vm-install-docker` → Docker CE 29.3.1 + Compose v5.1.1 (depends_on install-nginx) -- `examples/VM/variables.tf` — переменная `install_run_id` для управления rebuildом - ---- - -## Шаг 6: VM provisioning через sless_job (2026-03-29) - -### Цель -Установить пакеты, nginx и docker на свежую Nubes VM через sless_job. - -### Что реализовано -- `examples/VM/sless.tf` — три `sless_job` ресурса с `depends_on` цепочкой: - - `vm-install-packages` → jq, python3-pip, htop, unzip - - `vm-install-nginx` → nginx (depends_on install-packages) - - `vm-install-docker` → Docker CE 29.3.1 + Compose v5.1.1 (depends_on install-nginx) -- `examples/VM/variables.tf` — переменная `install_run_id` для управления rebuild'ом -- `examples/VM/outputs.tf` — вывод результатов job'ов -- `examples/VM/functions/install-packages/handler.py` — SSH установка пакетов -- `examples/VM/functions/install-nginx/handler.py` — SSH установка nginx -- `examples/VM/functions/install-docker/handler.py` — SSH установка Docker CE - -### Баги найдены и исправлены - -#### Баг 1: Ubuntu 22.04 — cloud-init держит apt lock при старте VM -- **Симптом**: `E: Could not get lock /var/lib/dpkg/lock-frontend (held by process 2191)` -- **Причина**: `unattended-upgrades` держит lock через cloud-init 15-20 мин после старта VM -- **Фикс**: `_wait_apt_lock()` начинает с `cloud-init status --wait` (timeout 300s), затем `systemctl mask` сервисов авто-обновления - -#### Баг 2: Оператор запускает СТАРЫЙ образ после upload -- **Симптом**: `image already exists in registry, skipping build` — новый код не применялся -- **Причина**: Go DEFLATE создавал идентичные байты zip для разных версий handler.py, sha256(zip) не менялся -- **Фикс**: изменение requirements.txt гарантирует уникальный zip hash - -#### Баг 3: WaitJobDone мгновенно возвращал ошибку на старый Failed статус -- **Симптом**: terraform apply падает мгновенно без ожидания нового build -- **Причина**: FunctionJob CRD хранил phase=Failed от предыдущего запуска -- **Воркараунд**: удалить FunctionJob CRD перед apply - -### Результат -- vm-install-packages: status=ok, installed=[jq, python3-pip, htop, unzip] -- vm-install-nginx: status=already_installed, version=1.18.0, http_check=200 -- vm-install-docker: status=ok, docker=29.3.1, compose=v5.1.1 - -### nubes провайдер -Обновлён с 5.0.49 до 5.0.51 (fix: findInstanceUidByDisplayNameRefSvc возвращал deleted instance UUID) diff --git a/examples/VM/VM_TEST_README.md b/examples/VM/VM_TEST_README.md new file mode 100644 index 0000000..ab9c7e4 --- /dev/null +++ b/examples/VM/VM_TEST_README.md @@ -0,0 +1,106 @@ +# VM Stress Test — Инструкция по запуску +# 2026-03-30 + +## ⛔⛔⛔ КРИТИЧЕСКИЕ ПРАВИЛА ⛔⛔⛔ + +### ЗАПРЕЩЕНО (без исключений): +- **НЕ РЕДАКТИРОВАТЬ** `terraform.tfvars` — там JWT-токен, потеря = катастрофа +- **НЕ РЕДАКТИРОВАТЬ** `*.tf` файлы +- **НЕ РЕДАКТИРОВАТЬ** `vm_stress_test.sh` +- **НЕ ЗАПУСКАТЬ** `terraform` напрямую — только через скрипт +- **НЕ СОЗДАВАТЬ** новые файлы в этой директории +- **НЕ ДЕЛАТЬ** `sed`, `awk`, `cat >`, `tee` в terraform.tfvars + +### ПОЧЕМУ: +Предыдущая версия скрипта содержала функцию `write_tfvars()` которая +перезаписывала `terraform.tfvars`. В процессе перезаписи был потерян +JWT-токен `api_token` (1200+ символов). Это привело к полному отказу +terraform и потере рабочего состояния. Восстановление заняло час. + +### КАК РАБОТАЕТ НОВЫЙ СКРИПТ: +Переменные переопределяются через `-var` в terraform CLI. +Файл `terraform.tfvars` читается terraform автоматически, +но **НИКОГДА не перезаписывается** скриптом. + +После каждой фазы проверяется md5sum terraform.tfvars. +Если файл изменился — **АВАРИЙНАЯ ОСТАНОВКА** (exit code 99). + +--- + +## Запуск + +### На VM (naeel@5.172.178.213): + +```bash +cd ~/terra/sless/examples/VM +bash vm_stress_test.sh 2>&1 | tee /tmp/vm_stress_$(date +%Y%m%d_%H%M).log +``` + +### Быстрый прогон (без destroy/resurrect — фазы 7-9 пропускаются): + +```bash +SKIP_DESTROY=1 bash vm_stress_test.sh 2>&1 | tee /tmp/vm_stress.log +``` + +### Количество stress-циклов (default: 2): + +```bash +STRESS_CYCLES=3 bash vm_stress_test.sh 2>&1 | tee /tmp/vm_stress.log +``` + +--- + +## Анализ результатов + +### Быстрый обзор: +```bash +grep -E '\[(PASS|FAIL|SKIP)\]' /tmp/vm_stress.log +``` + +### Только ошибки: +```bash +grep '\[FAIL\]' /tmp/vm_stress.log +``` + +### Итоговая сводка — последние 20 строк лога: +```bash +tail -20 /tmp/vm_stress.log +``` + +--- + +## Фазы теста + +| # | Имя | Что делает | +|---|-----------------|---------------------------------------------------| +| 1 | BASELINE | apply с полным набором (packages+nginx+docker) | +| 2 | IDEMPOTENT | plan → "No changes" (проверка идемпотентности) | +| 3 | PARTIAL_DISABLE | отключить nginx + docker через -var | +| 4 | PARTIAL_ENABLE | включить обратно nginx + docker | +| 5 | REORDER_PACKAGES| изменить набор base_packages через -var | +| 6 | MANUAL_PURGE | удалить пакеты с VM по SSH → переустановить | +| 7 | DESTROY | terraform destroy → VM в suspend | +| 8 | RESURRECT | apply после destroy → VM просыпается | +| 9 | STRESS_CYCLES | N циклов destroy/apply подряд | +|10 | FINAL_SANITY | финальная проверка VM + пакеты + plan | + +--- + +## Текущее состояние (baseline) + +5 ресурсов в state: +- `nubes_vapp.vapp` +- `nubes_vc_vm_v3.vm` +- `sless_job.install_packages[0]` +- `sless_job.install_nginx[0]` +- `sless_job.install_docker[0]` + +--- + +## Exit codes + +| Code | Значение | +|------|---------------------------------------------| +| 0 | Все тесты PASS | +| 1 | Есть FAIL (см. лог) | +| 99 | terraform.tfvars был изменён — АВАРИЙНЫЙ СТОП | diff --git a/examples/VM/vm_stress_test.sh b/examples/VM/vm_stress_test.sh new file mode 100755 index 0000000..b217d61 --- /dev/null +++ b/examples/VM/vm_stress_test.sh @@ -0,0 +1,847 @@ +#!/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_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 — повторный plan без изменений +# Передаём тот же run_id → ничего не изменилось → "No changes". +# ══════════════════════════════════════════════════════════════════════════════ + +phase_2_idempotent() { + phase_header 2 "IDEMPOTENT — повторный plan без изменений" + + # Тот же run_id что в предыдущей фазе → "No changes" + if tf_plan_no_changes \ + -var "install_packages=true" \ + -var "install_nginx=true" \ + -var "install_docker=true" \ + -var "install_run_id=$RUN_ID"; then + pass "2.1 terraform plan → No changes" + else + fail "2.1 terraform plan показывает изменения (ожидали No changes)" + grep -E 'will be|must be' /tmp/vm_tf_plan.log 2>/dev/null | head -5 | while read -r line; do + info " $line" + done + 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 + + # Подождать и проверить что пакеты вернулись + sleep 5 + + if vm_check_binary "$ip" "docker"; then + pass "6.5 docker установлен заново" + else + fail "6.5 docker НЕ установлен после re-apply" + fi + + if vm_check_binary "$ip" "nginx"; then + pass "6.6 nginx установлен заново" + else + fail "6.6 nginx НЕ установлен после re-apply" + 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 + + # Plan = no changes (с тем же run_id что и последний apply) + if tf_plan_no_changes \ + -var "install_packages=true" \ + -var "install_nginx=true" \ + -var "install_docker=true" \ + -var "install_run_id=$RUN_ID"; then + pass "10.2 terraform plan → No changes" + else + fail "10.2 terraform plan показывает изменения" + 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 + 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