From bad38aa62a593ba1c6e7f9cc02308b1ed776e63b Mon Sep 17 00:00:00 2001 From: Naeel Date: Mon, 30 Mar 2026 07:18:48 +0300 Subject: [PATCH] fix: sync vm stress test docs and ensure read-only behavior --- DEVfromGround/main.tf | 28 + DEVfromGround/vc_org.tf | 34 + NODEJS/main.tf | 32 + NODEJS/nodejs.tf | 22 + POSTGRES/chaos_marathon.tf | 301 ------- POSTGRES/code/calc-node/handler.js | 9 + POSTGRES/code/calc-node/package.json | 3 + POSTGRES/code/calc-python/handler.py | 105 +++ POSTGRES/code/calc-python/requirements.txt | 1 + .../code/chaos-badparams/chaos_badparams.py | 40 - .../code/chaos-bigpayload/chaos_bigpayload.py | 27 - POSTGRES/code/chaos-echo/chaos_echo.py | 19 - .../code/chaos-slowquery/chaos_slowquery.py | 19 - .../code/chaos-slowquery/requirements.txt | 1 - POSTGRES/code/funcs-list/funcs_list.py | 94 -- POSTGRES/code/funcs-list/requirements.txt | 1 - POSTGRES/code/go-counter-atomic/handler.go | 55 -- POSTGRES/code/go-pg-race/handler.go | 94 -- POSTGRES/code/js-pg-batch/js_pg_batch.js | 43 - POSTGRES/code/js-pg-batch/package.json | 7 - .../code/pg-bulk-insert/pg_bulk_insert.py | 38 - POSTGRES/code/pg-bulk-insert/requirements.txt | 1 - POSTGRES/code/pg-dedup/pg_dedup.py | 38 - POSTGRES/code/pg-dedup/requirements.txt | 1 - POSTGRES/code/pg-delete-old/pg_delete_old.py | 33 - POSTGRES/code/pg-delete-old/requirements.txt | 1 - POSTGRES/code/pg-search/pg_search.py | 36 - POSTGRES/code/pg-search/requirements.txt | 1 - POSTGRES/code/pg-upsert/pg_upsert.py | 36 - POSTGRES/code/pg-upsert/requirements.txt | 1 - .../code/py-retry-writer/py_retry_writer.py | 54 -- .../code/py-retry-writer/requirements.txt | 1 - POSTGRES/code/sql-runner/requirements.txt | 3 - POSTGRES/code/sql-runner/sql_runner.py | 39 - POSTGRES/code/stress-bigloop/requirements.txt | 0 .../code/stress-bigloop/stress_bigloop.py | 20 - POSTGRES/code/stress-divzero/requirements.txt | 0 .../code/stress-divzero/stress_divzero.py | 13 - POSTGRES/code/stress-go-fast/handler.go | 42 - POSTGRES/code/stress-go-nil/handler.go | 21 - POSTGRES/code/stress-go-pgstorm/handler.go | 148 --- POSTGRES/code/stress-js-async/package.json | 7 - .../code/stress-js-async/stress_js_async.js | 37 - POSTGRES/code/stress-js-badenv/package.json | 5 - .../code/stress-js-badenv/stress_js_badenv.js | 17 - POSTGRES/code/stress-slow/requirements.txt | 0 POSTGRES/code/stress-slow/stress_slow.py | 18 - POSTGRES/code/stress-writer/requirements.txt | 1 - POSTGRES/code/stress-writer/stress_writer.py | 39 - POSTGRES/code/table-rw/requirements.txt | 1 - POSTGRES/code/table-rw/table_rw.py | 133 --- POSTGRES/functions.tf | 117 +-- POSTGRES/main.tf | 2 +- POSTGRES/stress.tf | 167 ---- POSTGRES/test_cache_matrix.sh | 176 ++++ VM/.vm_stress_test.sh.OLD | 852 ++++++++++++++++++ VM/README.md | 93 ++ VM/VM_TEST_README.md | 106 +++ VM/functions/install-docker/handler.py | 158 ++++ VM/functions/install-docker/requirements.txt | 2 + VM/functions/install-nginx/handler.py | 129 +++ VM/functions/install-nginx/requirements.txt | 2 + VM/functions/install-packages/handler.py | 133 +++ .../install-packages/requirements.txt | 2 + VM/main.tf | 42 + VM/outputs.tf | 18 + VM/sless.tf | 108 +++ VM/vapp.tf | 28 + VM/variables.tf | 37 + VM/vm.tf | 37 + VM/vm_key | 7 + VM/vm_key.pub | 1 + VM/vm_stress_test.sh | 847 +++++++++++++++++ 73 files changed, 3037 insertions(+), 1747 deletions(-) create mode 100644 DEVfromGround/main.tf create mode 100644 DEVfromGround/vc_org.tf create mode 100644 NODEJS/main.tf create mode 100644 NODEJS/nodejs.tf delete mode 100644 POSTGRES/chaos_marathon.tf create mode 100644 POSTGRES/code/calc-node/handler.js create mode 100644 POSTGRES/code/calc-node/package.json create mode 100644 POSTGRES/code/calc-python/handler.py create mode 100644 POSTGRES/code/calc-python/requirements.txt delete mode 100644 POSTGRES/code/chaos-badparams/chaos_badparams.py delete mode 100644 POSTGRES/code/chaos-bigpayload/chaos_bigpayload.py delete mode 100644 POSTGRES/code/chaos-echo/chaos_echo.py delete mode 100644 POSTGRES/code/chaos-slowquery/chaos_slowquery.py delete mode 100644 POSTGRES/code/chaos-slowquery/requirements.txt delete mode 100644 POSTGRES/code/funcs-list/funcs_list.py delete mode 100644 POSTGRES/code/funcs-list/requirements.txt delete mode 100644 POSTGRES/code/go-counter-atomic/handler.go delete mode 100644 POSTGRES/code/go-pg-race/handler.go delete mode 100644 POSTGRES/code/js-pg-batch/js_pg_batch.js delete mode 100644 POSTGRES/code/js-pg-batch/package.json delete mode 100644 POSTGRES/code/pg-bulk-insert/pg_bulk_insert.py delete mode 100644 POSTGRES/code/pg-bulk-insert/requirements.txt delete mode 100644 POSTGRES/code/pg-dedup/pg_dedup.py delete mode 100644 POSTGRES/code/pg-dedup/requirements.txt delete mode 100644 POSTGRES/code/pg-delete-old/pg_delete_old.py delete mode 100644 POSTGRES/code/pg-delete-old/requirements.txt delete mode 100644 POSTGRES/code/pg-search/pg_search.py delete mode 100644 POSTGRES/code/pg-search/requirements.txt delete mode 100644 POSTGRES/code/pg-upsert/pg_upsert.py delete mode 100644 POSTGRES/code/pg-upsert/requirements.txt delete mode 100644 POSTGRES/code/py-retry-writer/py_retry_writer.py delete mode 100644 POSTGRES/code/py-retry-writer/requirements.txt delete mode 100644 POSTGRES/code/sql-runner/requirements.txt delete mode 100644 POSTGRES/code/sql-runner/sql_runner.py delete mode 100644 POSTGRES/code/stress-bigloop/requirements.txt delete mode 100644 POSTGRES/code/stress-bigloop/stress_bigloop.py delete mode 100644 POSTGRES/code/stress-divzero/requirements.txt delete mode 100644 POSTGRES/code/stress-divzero/stress_divzero.py delete mode 100644 POSTGRES/code/stress-go-fast/handler.go delete mode 100644 POSTGRES/code/stress-go-nil/handler.go delete mode 100644 POSTGRES/code/stress-go-pgstorm/handler.go delete mode 100644 POSTGRES/code/stress-js-async/package.json delete mode 100644 POSTGRES/code/stress-js-async/stress_js_async.js delete mode 100644 POSTGRES/code/stress-js-badenv/package.json delete mode 100644 POSTGRES/code/stress-js-badenv/stress_js_badenv.js delete mode 100644 POSTGRES/code/stress-slow/requirements.txt delete mode 100644 POSTGRES/code/stress-slow/stress_slow.py delete mode 100644 POSTGRES/code/stress-writer/requirements.txt delete mode 100644 POSTGRES/code/stress-writer/stress_writer.py delete mode 100644 POSTGRES/code/table-rw/requirements.txt delete mode 100644 POSTGRES/code/table-rw/table_rw.py delete mode 100644 POSTGRES/stress.tf create mode 100755 POSTGRES/test_cache_matrix.sh create mode 100755 VM/.vm_stress_test.sh.OLD create mode 100644 VM/README.md create mode 100644 VM/VM_TEST_README.md create mode 100644 VM/functions/install-docker/handler.py create mode 100644 VM/functions/install-docker/requirements.txt create mode 100644 VM/functions/install-nginx/handler.py create mode 100644 VM/functions/install-nginx/requirements.txt create mode 100644 VM/functions/install-packages/handler.py create mode 100644 VM/functions/install-packages/requirements.txt create mode 100644 VM/main.tf create mode 100644 VM/outputs.tf create mode 100644 VM/sless.tf create mode 100644 VM/vapp.tf create mode 100644 VM/variables.tf create mode 100644 VM/vm.tf create mode 100644 VM/vm_key create mode 100644 VM/vm_key.pub create mode 100755 VM/vm_stress_test.sh diff --git a/DEVfromGround/main.tf b/DEVfromGround/main.tf new file mode 100644 index 0000000..e17eacc --- /dev/null +++ b/DEVfromGround/main.tf @@ -0,0 +1,28 @@ +// 2026-03-26 — main.tf: провайдер Nubes для DEV-стенда. +// DEV API endpoint: https://deck-api-dev.ngcloud.ru/api/v1 +// Токен: secrets/dev.token (tazet@narod.ru) + +terraform { + required_providers { + nubes = { + source = "terra.k8c.ru/nubes/nubes" + version = "5.0.31" + } + } +} + +variable "api_token" { + type = string + sensitive = true + description = "Nubes API токен (DEV-стенд). Значение — в terraform.tfvars." +} + +variable "resource_realm" { + type = string + description = "Платформа развёртывания (например k8s-3.ext.nubes.ru). Уточнить у сервис-менеджера." +} + +provider "nubes" { + api_token = var.api_token + api_endpoint = "https://deck-api-dev.ngcloud.ru/api/v1/index.cfm" +} diff --git a/DEVfromGround/vc_org.tf b/DEVfromGround/vc_org.tf new file mode 100644 index 0000000..ee696aa --- /dev/null +++ b/DEVfromGround/vc_org.tf @@ -0,0 +1,34 @@ +// 2026-03-26 — vc_org.tf: ресурс «Организация в Cloud Director» для DEV-стенда. +// nubes_vc_org — тенант vCloud Director (organization_type = "iaas"). +// resource_realm задаётся через переменную (terraform.tfvars или -var). + +resource "nubes_vc_org" "dev_org" { + resource_name = "vcOrg-2" + resource_realm = var.resource_realm + + # organization_type "iaas" — единственный вариант с доступом к организации. + # Значение по умолчанию "iaas", явно прописано для читаемости. + organization_type = "iaas" + + # v_i_p_configure — JSON-список ipSpaces для операции modify. + # При create провайдер не передаёт его в API, но требует non-null значение в плане. + v_i_p_configure = "" + + # adopt_existing_on_create = true — берёт существующий инстанс (dev-org-sless-demo уже создан с null realm от предыдущей попытки). + adopt_existing_on_create = true + + # suspend_on_destroy = true (по умолчанию) — при destroy инстанс уходит в Suspend, не удаляется. + suspend_on_destroy = true +} + +# ─── Outputs ───────────────────────────────────────────────────────────────── + +output "dev_org_id" { + description = "ID созданной организации (используется в зависимых ресурсах)" + value = nubes_vc_org.dev_org.id +} + +output "dev_org_state_flat" { + description = "Плоский state организации — endpoints, статусы" + value = nubes_vc_org.dev_org.state_out_flat +} diff --git a/NODEJS/main.tf b/NODEJS/main.tf new file mode 100644 index 0000000..442dfba --- /dev/null +++ b/NODEJS/main.tf @@ -0,0 +1,32 @@ +// Создано: 2026-03-23 +// main.tf — провайдер Nubes + переменные для примера NODEJS. +// Ресурс nubes_nodejs: managed Node.js приложение в облаке (не sless-функция). + +terraform { + required_providers { + nubes = { + source = "terra.k8c.ru/nubes/nubes" + version = "5.0.19" + } + } +} + +variable "api_token" { + type = string + sensitive = true +} + +variable "realm" { + type = string + description = "resource_realm — зона размещения ресурса (например: k8s-3-sandbox-nubes-ru)" +} + +variable "git_path" { + type = string + description = "URL git-репозитория с кодом приложения" +} + +provider "nubes" { + api_token = var.api_token + api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" +} diff --git a/NODEJS/nodejs.tf b/NODEJS/nodejs.tf new file mode 100644 index 0000000..0e1102a --- /dev/null +++ b/NODEJS/nodejs.tf @@ -0,0 +1,22 @@ +# Создано: 2026-03-23 +# nodejs.tf — ресурс nubes_nodejs: managed Node.js приложение. +# Параметры взяты из документации terra.k8c.ru/docs/nubes/nubes/5.0.19/30_registry/resources/nodejs_params_create/ + +resource "nubes_nodejs" "app" { + resource_name = "nodejsdemo1" + domain = "domma" + resource_realm = var.realm + git_path = var.git_path + app_version = "23" + resource_c_p_u = 500 + resource_memory = 1024 + resource_instances = 1 + json_env = jsonencode({}) + adopt_existing_on_create = true + # health_path не задан — используется дефолтный / +} + +output "nodejs_domain" { + description = "Домен развёрнутого Node.js приложения" + value = nubes_nodejs.app.domain +} diff --git a/POSTGRES/chaos_marathon.tf b/POSTGRES/chaos_marathon.tf deleted file mode 100644 index 6fdb035..0000000 --- a/POSTGRES/chaos_marathon.tf +++ /dev/null @@ -1,301 +0,0 @@ -// 2026-03-21 — chaos_marathon.tf: 15 новых сервисов для часового хаос-марафона. -// Три рантайма: python3.11 (9), nodejs20 (2), go1.23 (2). -// Все зависят от sless_job.postgres_table_init_job. - -# ── Python: работа с таблицей ───────────────────────────────────────────────── - -# Считает строки по prefix — тест concurrent reads + COUNT агрегации. -resource "sless_service" "pg_counter" { - name = "pg-counter" - runtime = "python3.11" - entrypoint = "pg_counter.count" - memory_mb = 128 - timeout_sec = 15 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/pg-counter" - depends_on = [sless_job.postgres_table_init_job] -} - -# DELETE дублей по title — идемпотентный, повторный вызов безопасен. -resource "sless_service" "pg_dedup" { - name = "pg-dedup" - runtime = "python3.11" - entrypoint = "pg_dedup.dedup" - memory_mb = 128 - timeout_sec = 30 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/pg-dedup" - depends_on = [sless_job.postgres_table_init_job] -} - -# Поиск по title с ILIKE + пагинация — тест спецсимволов и SQL injection safety. -resource "sless_service" "pg_search" { - name = "pg-search" - runtime = "python3.11" - entrypoint = "pg_search.search" - memory_mb = 128 - timeout_sec = 15 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/pg-search" - depends_on = [sless_job.postgres_table_init_job] -} - -# Bulk INSERT через execute_values — до 500 строк за раз. -resource "sless_service" "pg_bulk_insert" { - name = "pg-bulk-insert" - runtime = "python3.11" - entrypoint = "pg_bulk_insert.bulk_insert" - memory_mb = 256 - timeout_sec = 30 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/pg-bulk-insert" - depends_on = [sless_job.postgres_table_init_job] -} - -# DELETE строк старше N минут — идемпотентный. -resource "sless_service" "pg_delete_old" { - name = "pg-delete-old" - runtime = "python3.11" - entrypoint = "pg_delete_old.delete_old" - memory_mb = 128 - timeout_sec = 30 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/pg-delete-old" - depends_on = [sless_job.postgres_table_init_job] -} - -# INSERT ON CONFLICT DO UPDATE — повторный вызов с тем же title безопасен. -resource "sless_service" "pg_upsert" { - name = "pg-upsert" - runtime = "python3.11" - entrypoint = "pg_upsert.upsert" - memory_mb = 128 - timeout_sec = 15 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/pg-upsert" - depends_on = [sless_job.postgres_table_init_job] -} - -# ── Python: chaos ───────────────────────────────────────────────────────────── - -# Echo: принимает любой ввод и отражает обратно — проверка на мусорный input. -resource "sless_service" "chaos_echo" { - name = "chaos-echo" - runtime = "python3.11" - entrypoint = "chaos_echo.echo" - memory_mb = 128 - timeout_sec = 10 - - source_dir = "${path.module}/code/chaos-echo" - depends_on = [sless_job.postgres_table_init_job] -} - -# Валидация плохих параметров — тупой юзер не может уронить сервис. -resource "sless_service" "chaos_badparams" { - name = "chaos-badparams" - runtime = "python3.11" - entrypoint = "chaos_badparams.validate" - memory_mb = 128 - timeout_sec = 10 - - source_dir = "${path.module}/code/chaos-badparams" - depends_on = [sless_job.postgres_table_init_job] -} - -# Медленный pg_sleep — тест timeout enforcement. -resource "sless_service" "chaos_slowquery" { - name = "chaos-slowquery" - runtime = "python3.11" - entrypoint = "chaos_slowquery.slowquery" - memory_mb = 128 - timeout_sec = 12 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/chaos-slowquery" - depends_on = [sless_job.postgres_table_init_job] -} - -# Большой JSON response — тест памяти и серилизации. -resource "sless_service" "chaos_bigpayload" { - name = "chaos-bigpayload" - runtime = "python3.11" - entrypoint = "chaos_bigpayload.bigpayload" - memory_mb = 256 - timeout_sec = 15 - - source_dir = "${path.module}/code/chaos-bigpayload" - depends_on = [sless_job.postgres_table_init_job] -} - -# ── Node.js ─────────────────────────────────────────────────────────────────── - -# Bulk INSERT через параметризованный multi-value query. -resource "sless_service" "js_pg_batch" { - name = "js-pg-batch" - runtime = "nodejs20" - entrypoint = "js_pg_batch.run" - memory_mb = 128 - timeout_sec = 30 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/js-pg-batch" - depends_on = [sless_job.postgres_table_init_job] -} - -# Идемпотентный INSERT — повторный вызов с тем же key = existing, не дубль. -resource "sless_service" "js_idempotent" { - name = "js-idempotent" - runtime = "nodejs20" - entrypoint = "js_idempotent.run" - memory_mb = 128 - timeout_sec = 15 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/js-idempotent" - depends_on = [sless_job.postgres_table_init_job] -} - -# ── Go 1.23 ─────────────────────────────────────────────────────────────────── - -# Параллельные concurrent INSERTs из N горутин внутри одного пода. -resource "sless_service" "go_pg_race" { - name = "go-pg-race" - runtime = "go1.23" - entrypoint = "handler.Handle" - memory_mb = 256 - timeout_sec = 30 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/go-pg-race" - depends_on = [sless_job.postgres_table_init_job] -} - -# Atomic-счётчик в памяти + PG INSERT на каждый вызов. -resource "sless_service" "go_counter_atomic" { - name = "go-counter-atomic" - runtime = "go1.23" - entrypoint = "handler.Handle" - memory_mb = 128 - timeout_sec = 15 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/go-counter-atomic" - depends_on = [sless_job.postgres_table_init_job] -} - -# ── Python: retry ───────────────────────────────────────────────────────────── - -# Запись с retry при transient PG error — тест устойчивости к сбоям. -resource "sless_service" "py_retry_writer" { - name = "py-retry-writer" - runtime = "python3.11" - entrypoint = "py_retry_writer.retry_write" - memory_mb = 128 - timeout_sec = 30 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/py-retry-writer" - depends_on = [sless_job.postgres_table_init_job] -} diff --git a/POSTGRES/code/calc-node/handler.js b/POSTGRES/code/calc-node/handler.js new file mode 100644 index 0000000..29bed1f --- /dev/null +++ b/POSTGRES/code/calc-node/handler.js @@ -0,0 +1,9 @@ +// Создано: 2026-04-10 +// Демо-функция: возвращает текущее время сервера. +// Юзер меняет код под себя и перебилдит через terraform apply. + +'use strict'; + +module.exports.handler = function handler(event) { + return `Текущее время: ${new Date().toISOString()}`; +}; diff --git a/POSTGRES/code/calc-node/package.json b/POSTGRES/code/calc-node/package.json new file mode 100644 index 0000000..18a1e41 --- /dev/null +++ b/POSTGRES/code/calc-node/package.json @@ -0,0 +1,3 @@ +{ + "dependencies": {} +} diff --git a/POSTGRES/code/calc-python/handler.py b/POSTGRES/code/calc-python/handler.py new file mode 100644 index 0000000..f775f35 --- /dev/null +++ b/POSTGRES/code/calc-python/handler.py @@ -0,0 +1,105 @@ +# Создано: 2026-04-10 +# Изменено: 2026-03-23 — упрощён до поля ввода выражения (демонстрация деплоя). +# Принимает произвольное математическое выражение: "2+2*(3-1)", "(10/3)**2" и т.д. +# GET → HTML страница с формой; POST с {expr} → вычисление через безопасный eval. +# Безопасность eval: __builtins__=None, только math-функции в locals. + +import math + +_PAGE = """ + + + + +Калькулятор — Python 3.11 + + + +
+

Калькулятор

+
Python 3.11 · runtime: sless
+ + +
+
+ + +""" + +# Разрешённые math-функции в eval — без __builtins__ нет доступа к exec/open/etc. +_MATH_LOCALS = {k: getattr(math, k) for k in dir(math) if not k.startswith('_')} + + +def handler(event): + if event.get('_method') == 'POST': + expr = str(event.get('expr', '')).strip() + return _compute(expr) + # GET → HTML страница + return _PAGE + + +def _compute(expr): + if not expr: + return {'error': 'Введите выражение'} + try: + result = eval(expr, {'__builtins__': None}, _MATH_LOCALS) # noqa: S307 + if not isinstance(result, (int, float)): + return {'error': 'Результат не является числом'} + return {'expr': expr, 'result': result} + except ZeroDivisionError: + return {'error': 'Деление на ноль'} + except Exception as exc: + return {'error': f'Ошибка: {exc}'} + + +def _esc(s): + # Экранируем HTML-спецсимволы — безопасный вывод в атрибут и тело. + return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') diff --git a/POSTGRES/code/calc-python/requirements.txt b/POSTGRES/code/calc-python/requirements.txt new file mode 100644 index 0000000..d45663c --- /dev/null +++ b/POSTGRES/code/calc-python/requirements.txt @@ -0,0 +1 @@ +# нет внешних зависимостей diff --git a/POSTGRES/code/chaos-badparams/chaos_badparams.py b/POSTGRES/code/chaos-badparams/chaos_badparams.py deleted file mode 100644 index cdfe032..0000000 --- a/POSTGRES/code/chaos-badparams/chaos_badparams.py +++ /dev/null @@ -1,40 +0,0 @@ -# 2026-03-21 — chaos-badparams: проверяет что функция не падает на мусорных входных данных. -# Принимает type=missing|wrong_type|huge|negative|zero и возвращает safe-ответ. -# Тестирует: устойчивость к "тупому юзеру" — никакого 500 на плохих входных данных. -import json - -_MAX_N = 10_000 - -def validate(event): - errors = [] - results = {} - - # n: должно быть int от 1 до MAX_N - raw_n = event.get("n") - try: - n = int(raw_n) - if n <= 0: - errors.append(f"n must be > 0, got {n}") - n = 1 - elif n > _MAX_N: - errors.append(f"n capped from {n} to {_MAX_N}") - n = _MAX_N - except (TypeError, ValueError): - errors.append(f"n is not a valid int: {repr(raw_n)}, using default 1") - n = 1 - results["n"] = n - - # name: обрезаем до 100 символов - raw_name = event.get("name", "") - if not isinstance(raw_name, str): - raw_name = str(raw_name) - errors.append("name was not a string, converted") - name = raw_name[:100] - results["name"] = name - - # flag: любое "truthy" значение - raw_flag = event.get("flag", False) - flag = raw_flag in (True, "true", "1", 1, "yes") - results["flag"] = flag - - return {"ok": len(errors) == 0, "errors": errors, "results": results} diff --git a/POSTGRES/code/chaos-bigpayload/chaos_bigpayload.py b/POSTGRES/code/chaos-bigpayload/chaos_bigpayload.py deleted file mode 100644 index 523e4d2..0000000 --- a/POSTGRES/code/chaos-bigpayload/chaos_bigpayload.py +++ /dev/null @@ -1,27 +0,0 @@ -# 2026-03-21 — chaos-bigpayload: генерирует/принимает большой JSON. -# Тестирует: большие ответы (64KB+), память рантайма. -import json, time - -def bigpayload(event): - size_kb = min(int(event.get("size_kb", 16)), 256) # cap 256KB - word = str(event.get("word", "x"))[:32] - - # Генерируем список строк нужного размера - chunk = word * 32 # ~32+ байт на запись - items = [] - total = 0 - target = size_kb * 1024 - i = 0 - while total < target: - entry = f"{chunk}-{i}" - items.append(entry) - total += len(entry) + 3 # 3 байта JSON overhead - i += 1 - - return { - "items_count": len(items), - "size_kb_approx": round(total / 1024, 1), - "first": items[0] if items else "", - "last": items[-1] if items else "", - "ts": int(time.time()), - } diff --git a/POSTGRES/code/chaos-echo/chaos_echo.py b/POSTGRES/code/chaos-echo/chaos_echo.py deleted file mode 100644 index 276023e..0000000 --- a/POSTGRES/code/chaos-echo/chaos_echo.py +++ /dev/null @@ -1,19 +0,0 @@ -# 2026-03-21 — chaos-echo: отражает входные данные обратно. -# Тестирует: большие payload, unicode, null, вложенные структуры, спецсимволы. -# "Тупой юзер" шлёт всё что угодно — функция должна вернуть это обратно без падения. -import json - -def echo(event): - # Пытаемся сериализовать обратно — выловит непериализуемые типы - try: - size = len(json.dumps(event)) - except Exception: - size = -1 - - keys = list(event.keys()) if isinstance(event, dict) else [] - return { - "echo": event, - "keys": keys, - "size_bytes": size, - "type": type(event).__name__, - } diff --git a/POSTGRES/code/chaos-slowquery/chaos_slowquery.py b/POSTGRES/code/chaos-slowquery/chaos_slowquery.py deleted file mode 100644 index 6b9fb42..0000000 --- a/POSTGRES/code/chaos-slowquery/chaos_slowquery.py +++ /dev/null @@ -1,19 +0,0 @@ -# 2026-03-21 — chaos-slowquery: намеренно медленный запрос через pg_sleep. -# Тестирует: timeout enforcement — платформа должна прервать запрос если > timeout_sec. -# sleep_sec cap = 8 (меньше timeout_sec=10 сервиса → успех; >10 → таймаут платформы). -import os, psycopg2 - -def slowquery(event): - sleep_sec = min(float(event.get("sleep_sec", 2.0)), 8.0) - conn = psycopg2.connect( - host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)), - dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"], - password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"), - ) - try: - with conn.cursor() as cur: - cur.execute("SELECT pg_sleep(%s), now()::text", (sleep_sec,)) - result = cur.fetchone() - return {"slept_sec": sleep_sec, "pg_now": result[1]} - finally: - conn.close() diff --git a/POSTGRES/code/chaos-slowquery/requirements.txt b/POSTGRES/code/chaos-slowquery/requirements.txt deleted file mode 100644 index 37ec460..0000000 --- a/POSTGRES/code/chaos-slowquery/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -psycopg2-binary diff --git a/POSTGRES/code/funcs-list/funcs_list.py b/POSTGRES/code/funcs-list/funcs_list.py deleted file mode 100644 index 68157d2..0000000 --- a/POSTGRES/code/funcs-list/funcs_list.py +++ /dev/null @@ -1,94 +0,0 @@ -# 2026-03-18 (обновлено: plain text вывод; фильтрация SLESS_EXCLUDE) -# funcs_list.py — HTTP-функция: список пользовательских функций, человекочитаемый plain text. -# Вызывает внутренний REST API оператора (ClusterIP, без TLS). -# Возвращает str → python runtime отдаёт text/plain напрямую без json.dumps. -# -# Env vars: -# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090) -# SLESS_NAMESPACE — namespace пользователя (sless-{hex16}) -# SLESS_TOKEN — JWT токен для /v1/ API -# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru) -# SLESS_EXCLUDE — comma-separated имена функций, которые не показывать - -import os -import requests - -SEP = "─" * 52 - - -def _comment(fn, http_trigs, cron_trigs): - phase = fn.get("phase", "?") - runtime = fn.get("runtime", "?") - if http_trigs: - active = "активна" if http_trigs[0].get("active") else "неактивна" - return f"HTTP endpoint ({runtime}) — {phase}, {active}" - elif cron_trigs: - schedule = cron_trigs[0].get("schedule", "?") - active = "активна" if cron_trigs[0].get("active") else "неактивна" - return f"Cron '{schedule}' ({runtime}) — {phase}, {active}" - else: - return f"Job/runner без триггера ({runtime}) — {phase}" - - -def list_all(event): - api_url = os.environ["SLESS_API_URL"].rstrip("/") - namespace = os.environ["SLESS_NAMESPACE"] - token = os.environ["SLESS_TOKEN"] - ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/") - exclude = {n.strip() for n in os.environ.get("SLESS_EXCLUDE", "").split(",") if n.strip()} - - headers = {"Authorization": f"Bearer {token}"} - fns = requests.get(f"{api_url}/v1/namespaces/{namespace}/functions", headers=headers, timeout=10) - trs = requests.get(f"{api_url}/v1/namespaces/{namespace}/triggers", headers=headers, timeout=10) - fns.raise_for_status() - trs.raise_for_status() - - trig_idx = {} - for tr in trs.json(): - fn_name = tr.get("function") or tr.get("functionRef") - if fn_name: - trig_idx.setdefault(fn_name, []).append(tr) - - items = [] - for fn in fns.json(): - name = fn["name"] - if name in exclude: - continue - http_t = [t for t in trig_idx.get(name, []) if t.get("type") == "http"] - cron_t = [t for t in trig_idx.get(name, []) if t.get("type") == "cron"] - is_active = any(t.get("enabled", True) and t.get("active", False) for t in trig_idx.get(name, [])) - items.append((fn, http_t, cron_t, is_active)) - - # Сортировка: активные вверх, затем по имени - items.sort(key=lambda x: (not x[3], x[0]["name"])) - - lines = [] - for fn, http_t, cron_t, is_active in items: - name = fn["name"] - lines.append(SEP) - lines.append(f" {_comment(fn, http_t, cron_t)}") - lines.append(f" name: {name}") - lines.append(f" runtime: {fn.get('runtime', '?')}") - lines.append(f" phase: {fn.get('phase', '?')}") - lines.append(f" active: {'да' if is_active else 'нет'}") - - if http_t: - url = f"{ext_url}/fn/{namespace}/{name}" if ext_url else http_t[0].get("url", "") - lines.append(f" url: {url}") - if cron_t: - lines.append(f" cron: {cron_t[0].get('schedule', '?')}") - if fn.get("created_at"): - lines.append(f" created: {fn['created_at']}") - if fn.get("last_built_at"): - lines.append(f" built: {fn['last_built_at']}") - if fn.get("message"): - lines.append(f" message: {fn['message']}") - - lines.append(SEP) - lines.append(f" namespace: {namespace} | total: {len(items)}") - lines.append(SEP) - - # Возвращаем str — python runtime отдаст text/plain напрямую - return "\n".join(lines) + "\n" - - diff --git a/POSTGRES/code/funcs-list/requirements.txt b/POSTGRES/code/funcs-list/requirements.txt deleted file mode 100644 index 2c24336..0000000 --- a/POSTGRES/code/funcs-list/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -requests==2.31.0 diff --git a/POSTGRES/code/go-counter-atomic/handler.go b/POSTGRES/code/go-counter-atomic/handler.go deleted file mode 100644 index e101ab3..0000000 --- a/POSTGRES/code/go-counter-atomic/handler.go +++ /dev/null @@ -1,55 +0,0 @@ -// 2026-03-21 — go-counter-atomic: считает вызовы через atomic в памяти + пишет в PG. -// Тестирует: in-memory state между вызовами (Go pod остаётся живым), + PG INSERT на каждый вызов. -package handler - -import ( - "context" - "fmt" - "os" - "sync/atomic" - "time" - - "github.com/jackc/pgx/v5/pgxpool" -) - -// invocations считается между вызовами (пока pod жив). -var invocations int64 - -// Handle записывает факт вызова в PG и возвращает накопленный счётчик. -func Handle(event map[string]interface{}) interface{} { - n := atomic.AddInt64(&invocations, 1) - - dsn := fmt.Sprintf( - "host=%s port=%s dbname=%s user=%s password=%s sslmode=%s", - os.Getenv("PGHOST"), envOrDefault("PGPORT", "5432"), - os.Getenv("PGDATABASE"), os.Getenv("PGUSER"), - os.Getenv("PGPASSWORD"), envOrDefault("PGSSLMODE", "require"), - ) - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - return map[string]interface{}{"invocation_n": n, "error": err.Error()} - } - defer pool.Close() - - title := fmt.Sprintf("go-counter-invoke-%d-%d", n, time.Now().UnixMilli()) - var id int64 - err = pool.QueryRow(context.Background(), - "INSERT INTO terraform_demo_table (title) VALUES ($1) RETURNING id", title, - ).Scan(&id) - if err != nil { - return map[string]interface{}{"invocation_n": n, "error": err.Error()} - } - - return map[string]interface{}{ - "invocation_n": n, - "inserted_id": id, - "title": title, - } -} - -func envOrDefault(key, def string) string { - if v := os.Getenv(key); v != "" { - return v - } - return def -} diff --git a/POSTGRES/code/go-pg-race/handler.go b/POSTGRES/code/go-pg-race/handler.go deleted file mode 100644 index 4dd1b39..0000000 --- a/POSTGRES/code/go-pg-race/handler.go +++ /dev/null @@ -1,94 +0,0 @@ -// 2026-03-21 — go-pg-race: параллельные INSERT из нескольких горутин внутри одной функции. -// Тестирует: race condition устойчивость Go + PG при concurrent writes из одного пода. -// Использует pgx/v5 (pre-cached в base image). -package handler - -import ( - "context" - "fmt" - "os" - "sync" - "sync/atomic" - "time" - - "github.com/jackc/pgx/v5/pgxpool" -) - -// Handle запускает workers горутин, каждая делает n_per_worker INSERTs. -func Handle(event map[string]interface{}) interface{} { - workers := intParam(event, "workers", 5) - if workers > 20 { - workers = 20 - } - nPerWorker := intParam(event, "n_per_worker", 10) - if nPerWorker > 50 { - nPerWorker = 50 - } - - dsn := fmt.Sprintf( - "host=%s port=%s dbname=%s user=%s password=%s sslmode=%s", - os.Getenv("PGHOST"), getenv("PGPORT", "5432"), - os.Getenv("PGDATABASE"), os.Getenv("PGUSER"), - os.Getenv("PGPASSWORD"), getenv("PGSSLMODE", "require"), - ) - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - return map[string]interface{}{"error": err.Error()} - } - defer pool.Close() - - var ( - wg sync.WaitGroup - ok int64 - errCount int64 - ) - t0 := time.Now() - for w := 0; w < workers; w++ { - wg.Add(1) - go func(wid int) { - defer wg.Done() - for i := 0; i < nPerWorker; i++ { - title := fmt.Sprintf("go-race-w%d-%d-%d", wid, time.Now().UnixMilli(), i) - _, err := pool.Exec(context.Background(), - "INSERT INTO terraform_demo_table (title) VALUES ($1)", title) - if err != nil { - atomic.AddInt64(&errCount, 1) - } else { - atomic.AddInt64(&ok, 1) - } - } - }(w) - } - wg.Wait() - elapsed := time.Since(t0).Seconds() - - return map[string]interface{}{ - "workers": workers, - "n_per_worker": nPerWorker, - "inserted": ok, - "errors": errCount, - "elapsed_sec": elapsed, - "ops_per_sec": float64(ok) / elapsed, - } -} - -func intParam(event map[string]interface{}, key string, def int) int { - v, ok := event[key] - if !ok { - return def - } - switch val := v.(type) { - case float64: - return int(val) - case int: - return val - } - return def -} - -func getenv(key, def string) string { - if v := os.Getenv(key); v != "" { - return v - } - return def -} diff --git a/POSTGRES/code/js-pg-batch/js_pg_batch.js b/POSTGRES/code/js-pg-batch/js_pg_batch.js deleted file mode 100644 index c95eb45..0000000 --- a/POSTGRES/code/js-pg-batch/js_pg_batch.js +++ /dev/null @@ -1,43 +0,0 @@ -// 2026-03-21 — js-pg-batch: вставляет N строк через parameterized bulk query. -// Тестирует: async/await PG с пакетной вставкой, Node.js под нагрузкой. -const { Client } = require('pg'); - -async function run(event) { - const n = Math.min(parseInt(event.n ?? 20, 10) || 20, 200); - const prefix = String(event.prefix ?? 'js-batch').slice(0, 40); - - const client = new Client({ - host: process.env.PGHOST, - port: parseInt(process.env.PGPORT ?? '5432'), - database: process.env.PGDATABASE, - user: process.env.PGUSER, - password: process.env.PGPASSWORD, - ssl: { rejectUnauthorized: false }, - }); - await client.connect(); - - try { - const ts = Date.now(); - // Строим multi-value INSERT: INSERT INTO ... VALUES ($1), ($2), ... - const placeholders = []; - const values = []; - for (let i = 0; i < n; i++) { - placeholders.push(`($${i + 1})`); - values.push(`${prefix}-${ts}-${i}`); - } - const sql = `INSERT INTO terraform_demo_table (title) VALUES ${placeholders.join(',')} RETURNING id`; - const t0 = Date.now(); - const res = await client.query(sql, values); - const elapsed = (Date.now() - t0) / 1000; - - return { - inserted: res.rowCount, - first_id: res.rows[0]?.id ?? null, - elapsed_sec: elapsed, - }; - } finally { - await client.end(); - } -} - -module.exports = { run }; diff --git a/POSTGRES/code/js-pg-batch/package.json b/POSTGRES/code/js-pg-batch/package.json deleted file mode 100644 index f484622..0000000 --- a/POSTGRES/code/js-pg-batch/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "js-pg-batch", - "version": "1.0.0", - "dependencies": { - "pg": "^8.11.3" - } -} diff --git a/POSTGRES/code/pg-bulk-insert/pg_bulk_insert.py b/POSTGRES/code/pg-bulk-insert/pg_bulk_insert.py deleted file mode 100644 index 555ffbe..0000000 --- a/POSTGRES/code/pg-bulk-insert/pg_bulk_insert.py +++ /dev/null @@ -1,38 +0,0 @@ -# 2026-03-21 — pg-bulk-insert: bulk INSERT через execute_values. -# Тестирует: большие батчи (до 500 строк), производительность, память. -import os, time, psycopg2, psycopg2.extras - -def bulk_insert(event): - try: - n = max(0, min(int(event.get("n", 50)), 500)) # cap 500, min 0 - except (TypeError, ValueError): - n = 50 - prefix = str(event.get("prefix", "bulk"))[:50] - ts = int(time.time() * 1000) - - # n=0 — граничный случай: вернуть сразу без обращения к PG. - if n == 0: - return {"inserted": 0, "first_id": None, "elapsed_sec": 0.0} - - rows = [(f"{prefix}-{ts}-{i}",) for i in range(n)] - - conn = psycopg2.connect( - host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)), - dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"], - password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"), - ) - try: - t0 = time.time() - with conn.cursor() as cur: - psycopg2.extras.execute_values( - cur, - "INSERT INTO terraform_demo_table (title) VALUES %s RETURNING id", - rows, - page_size=100, - ) - ids = [r[0] for r in cur.fetchall()] - conn.commit() - elapsed = round(time.time() - t0, 3) - return {"inserted": len(ids), "first_id": ids[0] if ids else None, "elapsed_sec": elapsed} - finally: - conn.close() diff --git a/POSTGRES/code/pg-bulk-insert/requirements.txt b/POSTGRES/code/pg-bulk-insert/requirements.txt deleted file mode 100644 index 37ec460..0000000 --- a/POSTGRES/code/pg-bulk-insert/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -psycopg2-binary diff --git a/POSTGRES/code/pg-dedup/pg_dedup.py b/POSTGRES/code/pg-dedup/pg_dedup.py deleted file mode 100644 index 18cba85..0000000 --- a/POSTGRES/code/pg-dedup/pg_dedup.py +++ /dev/null @@ -1,38 +0,0 @@ -# 2026-03-21 — pg-dedup: удаляет дубликаты по title, оставляет первый (min id). -# Тестирует: DELETE с subquery, CTE, idempotency (повторный вызов безопасен). -import os, psycopg2 - -def dedup(event): - dry_run = str(event.get("dry_run", "false")).lower() in ("true", "1", "yes") - conn = psycopg2.connect( - host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)), - dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"], - password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"), - ) - try: - with conn.cursor() as cur: - # Считаем сколько дублей есть - cur.execute(""" - SELECT COUNT(*) FROM terraform_demo_table t1 - WHERE EXISTS ( - SELECT 1 FROM terraform_demo_table t2 - WHERE t2.title = t1.title AND t2.id < t1.id - ) - """) - dupes_count = cur.fetchone()[0] - - if not dry_run and dupes_count > 0: - cur.execute(""" - DELETE FROM terraform_demo_table - WHERE id NOT IN ( - SELECT MIN(id) FROM terraform_demo_table GROUP BY title - ) - """) - deleted = cur.rowcount - conn.commit() - else: - deleted = 0 - - return {"duplicates_found": dupes_count, "deleted": deleted, "dry_run": dry_run} - finally: - conn.close() diff --git a/POSTGRES/code/pg-dedup/requirements.txt b/POSTGRES/code/pg-dedup/requirements.txt deleted file mode 100644 index 37ec460..0000000 --- a/POSTGRES/code/pg-dedup/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -psycopg2-binary diff --git a/POSTGRES/code/pg-delete-old/pg_delete_old.py b/POSTGRES/code/pg-delete-old/pg_delete_old.py deleted file mode 100644 index d9655b3..0000000 --- a/POSTGRES/code/pg-delete-old/pg_delete_old.py +++ /dev/null @@ -1,33 +0,0 @@ -# 2026-03-21 — pg-delete-old: удаляет строки старше N минут (default 60). -# Тестирует: DELETE с RETURNING, идемпотентность (повторный вызов = 0 удалений если нет старых). -import os, psycopg2, psycopg2.extras - -def delete_old(event): - older_than_min = max(int(event.get("older_than_min", 60)), 1) - prefix_filter = event.get("prefix", "") - - conn = psycopg2.connect( - host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)), - dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"], - password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"), - ) - try: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - if prefix_filter: - cur.execute( - "DELETE FROM terraform_demo_table " - "WHERE created_at < now() - interval '1 minute' * %s " - "AND title LIKE %s RETURNING id, title", - (older_than_min, f"{prefix_filter}%"), - ) - else: - cur.execute( - "DELETE FROM terraform_demo_table " - "WHERE created_at < now() - interval '1 minute' * %s RETURNING id, title", - (older_than_min,), - ) - deleted = [dict(r) for r in cur.fetchall()] - conn.commit() - return {"deleted": len(deleted), "older_than_min": older_than_min, "sample": deleted[:5]} - finally: - conn.close() diff --git a/POSTGRES/code/pg-delete-old/requirements.txt b/POSTGRES/code/pg-delete-old/requirements.txt deleted file mode 100644 index 37ec460..0000000 --- a/POSTGRES/code/pg-delete-old/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -psycopg2-binary diff --git a/POSTGRES/code/pg-search/pg_search.py b/POSTGRES/code/pg-search/pg_search.py deleted file mode 100644 index fa62669..0000000 --- a/POSTGRES/code/pg-search/pg_search.py +++ /dev/null @@ -1,36 +0,0 @@ -# 2026-03-21 — pg-search: полнотекстовый поиск по title через ILIKE + LIMIT/OFFSET. -# Тестирует: пагинацию, спецсимволы в input (XSS, SQL injection attempt → безопасно через параметры). -import os, psycopg2, psycopg2.extras - -def search(event): - # «query» — основной параметр (user-friendly), «q» — алиас для совместимости. - query = str(event.get("query") or event.get("q") or "")[:200] - # int() может упасть если юзер прислал строку — защищаем try/except. - try: - limit = max(1, min(int(event.get("limit", 20)), 100)) - except (TypeError, ValueError): - limit = 20 - try: - offset = max(0, int(event.get("offset", 0))) - except (TypeError, ValueError): - offset = 0 - - conn = psycopg2.connect( - host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)), - dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"], - password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"), - ) - try: - with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: - pattern = f"%{query}%" if query else "%" - cur.execute( - "SELECT id, title, created_at::text FROM terraform_demo_table " - "WHERE title ILIKE %s ORDER BY id DESC LIMIT %s OFFSET %s", - (pattern, limit, offset), - ) - rows = [dict(r) for r in cur.fetchall()] - cur.execute("SELECT COUNT(*) FROM terraform_demo_table WHERE title ILIKE %s", (pattern,)) - total = cur.fetchone()["count"] - return {"rows": rows, "count": len(rows), "total": total, "q": query, "limit": limit, "offset": offset} - finally: - conn.close() diff --git a/POSTGRES/code/pg-search/requirements.txt b/POSTGRES/code/pg-search/requirements.txt deleted file mode 100644 index 37ec460..0000000 --- a/POSTGRES/code/pg-search/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -psycopg2-binary diff --git a/POSTGRES/code/pg-upsert/pg_upsert.py b/POSTGRES/code/pg-upsert/pg_upsert.py deleted file mode 100644 index 7159cfc..0000000 --- a/POSTGRES/code/pg-upsert/pg_upsert.py +++ /dev/null @@ -1,36 +0,0 @@ -# 2026-03-21 — pg-upsert: INSERT ... ON CONFLICT (title) DO UPDATE. -# Тестирует: идемпотентность вставки — один и тот же title можно вызывать 100 раз подряд. -# Требует уникального индекса на title — создаётся при первом вызове (CREATE UNIQUE INDEX IF NOT EXISTS). -import os, psycopg2 - -def upsert(event): - title = str(event.get("title", "upsert-default"))[:255] - payload = str(event.get("payload", ""))[:500] - - conn = psycopg2.connect( - host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)), - dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"], - password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"), - ) - try: - with conn.cursor() as cur: - # Создаём уникальный индекс если нет — для поддержки ON CONFLICT - cur.execute( - "CREATE UNIQUE INDEX IF NOT EXISTS terraform_demo_table_title_uniq " - "ON terraform_demo_table (title)" - ) - cur.execute( - "INSERT INTO terraform_demo_table (title) VALUES (%s) " - "ON CONFLICT (title) DO UPDATE SET created_at = now() " - "RETURNING id, title, created_at::text, xmax", - (title,), - ) - row = cur.fetchone() - was_insert = row[3] == 0 # xmax=0 означает INSERT, иначе UPDATE - conn.commit() - return { - "id": row[0], "title": row[1], "created_at": row[2], - "action": "inserted" if was_insert else "updated", - } - finally: - conn.close() diff --git a/POSTGRES/code/pg-upsert/requirements.txt b/POSTGRES/code/pg-upsert/requirements.txt deleted file mode 100644 index 37ec460..0000000 --- a/POSTGRES/code/pg-upsert/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -psycopg2-binary diff --git a/POSTGRES/code/py-retry-writer/py_retry_writer.py b/POSTGRES/code/py-retry-writer/py_retry_writer.py deleted file mode 100644 index fbc5e56..0000000 --- a/POSTGRES/code/py-retry-writer/py_retry_writer.py +++ /dev/null @@ -1,54 +0,0 @@ -# 2026-03-21 — py-retry-writer: пишет N строк с retry при PG ошибке. -# Тестирует: устойчивость к transient PG errors (simulate_error=true), retry logic, -# корректный rollback при частичном сбое. -import os, time, psycopg2, random - -_MAX_RETRIES = 3 - -def retry_write(event): - n = min(int(event.get("n", 5)), 100) - prefix = str(event.get("prefix", "retry"))[:40] - # simulate_error: с вероятностью 30% кидает OperationalError на 2-й попытке - simulate = str(event.get("simulate_error", "false")).lower() in ("true", "1") - - attempt = 0 - last_err = None - - while attempt < _MAX_RETRIES: - attempt += 1 - try: - conn = psycopg2.connect( - host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)), - dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"], - password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"), - ) - inserted = [] - try: - with conn.cursor() as cur: - for i in range(n): - # Симуляция: на первой попытке падаем с вероятностью 50% - if simulate and attempt == 1 and i == n // 2: - raise psycopg2.OperationalError("simulated transient error") - title = f"{prefix}-{int(time.time()*1000)}-{i}-a{attempt}" - cur.execute( - "INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id", - (title,), - ) - inserted.append(cur.fetchone()[0]) - conn.commit() - return { - "ok": True, "inserted": len(inserted), - "attempts": attempt, "first_id": inserted[0] if inserted else None, - } - except Exception as e: - conn.rollback() - raise - finally: - conn.close() - except psycopg2.OperationalError as e: - last_err = str(e) - if attempt < _MAX_RETRIES: - time.sleep(0.3 * attempt) # exponential backoff - continue - - return {"ok": False, "attempts": attempt, "last_error": last_err} diff --git a/POSTGRES/code/py-retry-writer/requirements.txt b/POSTGRES/code/py-retry-writer/requirements.txt deleted file mode 100644 index 37ec460..0000000 --- a/POSTGRES/code/py-retry-writer/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -psycopg2-binary diff --git a/POSTGRES/code/sql-runner/requirements.txt b/POSTGRES/code/sql-runner/requirements.txt deleted file mode 100644 index 56ae88a..0000000 --- a/POSTGRES/code/sql-runner/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -# 2026-03-17 00:00 -# requirements.txt — зависимости для функции запуска SQL. -psycopg2-binary==2.9.9 diff --git a/POSTGRES/code/sql-runner/sql_runner.py b/POSTGRES/code/sql-runner/sql_runner.py deleted file mode 100644 index cca9e03..0000000 --- a/POSTGRES/code/sql-runner/sql_runner.py +++ /dev/null @@ -1,39 +0,0 @@ -# 2026-03-17 00:00 -# sql_runner.py — функция для выполнения SQL-операторов из входного события. -import os -import psycopg2 - - -def run_sql(event): - # Выполняет список SQL-операторов в одной транзакции для атомарной инициализации схемы. - # Параметры подключения передаются раздельно, чтобы избежать ошибок парсинга DSN при спецсимволах. - pg_host = os.environ["PGHOST"] - pg_port = os.environ.get("PGPORT", "5432") - pg_database = os.environ["PGDATABASE"] - pg_user = os.environ["PGUSER"] - pg_password = os.environ["PGPASSWORD"] - pg_sslmode = os.environ.get("PGSSLMODE", "require") - statements = event.get("statements", []) - - if not statements: - return {"error": "no statements provided"} - - connection = psycopg2.connect( - host=pg_host, - port=pg_port, - dbname=pg_database, - user=pg_user, - password=pg_password, - sslmode=pg_sslmode, - ) - try: - cursor = connection.cursor() - for statement in statements: - cursor.execute(statement) - connection.commit() - return {"ok": True, "executed": len(statements)} - except Exception as error: - connection.rollback() - return {"error": str(error)} - finally: - connection.close() diff --git a/POSTGRES/code/stress-bigloop/requirements.txt b/POSTGRES/code/stress-bigloop/requirements.txt deleted file mode 100644 index e69de29..0000000 diff --git a/POSTGRES/code/stress-bigloop/stress_bigloop.py b/POSTGRES/code/stress-bigloop/stress_bigloop.py deleted file mode 100644 index c9ce935..0000000 --- a/POSTGRES/code/stress-bigloop/stress_bigloop.py +++ /dev/null @@ -1,20 +0,0 @@ -# 2026-03-19 -# stress_bigloop.py — CPU-интенсивная функция: считает сумму квадратов N чисел. -# Проверяет поведение под нагрузкой (большая и средняя итерация). - -import time - -_VERSION = "v1" - - -def run(event): - n = int(event.get("n", 500_000)) - start = time.monotonic() - total = sum(i * i for i in range(n)) - elapsed = round(time.monotonic() - start, 4) - return { - "version": _VERSION, - "n": n, - "sum_of_squares": total, - "elapsed_sec": elapsed, - } diff --git a/POSTGRES/code/stress-divzero/requirements.txt b/POSTGRES/code/stress-divzero/requirements.txt deleted file mode 100644 index e69de29..0000000 diff --git a/POSTGRES/code/stress-divzero/stress_divzero.py b/POSTGRES/code/stress-divzero/stress_divzero.py deleted file mode 100644 index 86746e5..0000000 --- a/POSTGRES/code/stress-divzero/stress_divzero.py +++ /dev/null @@ -1,13 +0,0 @@ -# 2026-03-19 -# stress_divzero.py — намеренно делит на ноль (ZeroDivisionError). -# Проверяет: платформа перехватывает панику, возвращает HTTP 500, не роняет под. - -_VERSION = "v1" - - -def run(event): - numerator = int(event.get("n", 42)) - denominator = int(event.get("d", 0)) # по умолчанию 0 — намеренный краш - # ZeroDivisionError: проверяем что платформа обрабатывает исключения - result = numerator / denominator - return {"version": _VERSION, "result": result} diff --git a/POSTGRES/code/stress-go-fast/handler.go b/POSTGRES/code/stress-go-fast/handler.go deleted file mode 100644 index 9f40709..0000000 --- a/POSTGRES/code/stress-go-fast/handler.go +++ /dev/null @@ -1,42 +0,0 @@ -// 2026-03-19 -// handler.go — быстрая Go функция: факториал + числа Фибоначчи. -// Проверяет Go runtime под лёгкой нагрузкой и корректность JSON-ответа. -// Entrypoint: handler.Handle -package handler - -import "fmt" - -func factorial(n int) uint64 { - if n <= 1 { - return 1 - } - return uint64(n) * factorial(n-1) -} - -func fib(n int) int { - if n <= 1 { - return n - } - a, b := 0, 1 - for i := 2; i <= n; i++ { - a, b = b, a+b - } - return b -} - -func Handle(event map[string]interface{}) interface{} { - n := 10 - if v, ok := event["n"].(float64); ok { - n = int(v) - if n > 20 { - n = 20 - } - } - return map[string]interface{}{ - "runtime": "go1.23", - "version": "v1", - "n": n, - "factorial": fmt.Sprintf("%d", factorial(n)), - "fib": fib(n), - } -} diff --git a/POSTGRES/code/stress-go-nil/handler.go b/POSTGRES/code/stress-go-nil/handler.go deleted file mode 100644 index 0ee25f6..0000000 --- a/POSTGRES/code/stress-go-nil/handler.go +++ /dev/null @@ -1,21 +0,0 @@ -// 2026-03-19 -// handler.go — намеренный nil pointer dereference в Go. -// Проверяет что Go runtime recover() перехватывает панику и платформа возвращает 500. -// Entrypoint: handler.Handle -package handler - -func Handle(event map[string]interface{}) interface{} { - crash := true - if v, ok := event["crash"].(bool); ok { - crash = v - } - if crash { - var p *string - _ = *p // panic: намеренный nil pointer для stress-теста - } - return map[string]interface{}{ - "runtime": "go1.23", - "version": "v1", - "crashed": false, - } -} diff --git a/POSTGRES/code/stress-go-pgstorm/handler.go b/POSTGRES/code/stress-go-pgstorm/handler.go deleted file mode 100644 index edf4668..0000000 --- a/POSTGRES/code/stress-go-pgstorm/handler.go +++ /dev/null @@ -1,148 +0,0 @@ -// 2026-03-19 -// handler.go — Go стресс-тест PostgreSQL через pgxpool. -// Запускает N горутин (default 100), каждая в цикле duration_sec (default 600) -// долбит PG попеременно: INSERT / SELECT COUNT / SELECT MAX с случайными задержками. -// Цель: проверить Go runtime под конкурентной нагрузкой и устойчивость PG connection pool. -// Entrypoint: handler.Handle -package handler - -import ( - "context" - "fmt" - "math/rand" - "os" - "sync" - "sync/atomic" - "time" - - "github.com/jackc/pgx/v5/pgxpool" -) - -// pgDSN собирает DSN из env vars (PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE). -func pgDSN() string { - host := os.Getenv("PGHOST") - port := os.Getenv("PGPORT") - if port == "" { - port = "5432" - } - db := os.Getenv("PGDATABASE") - user := os.Getenv("PGUSER") - pass := os.Getenv("PGPASSWORD") - sslmode := os.Getenv("PGSSLMODE") - if sslmode == "" { - sslmode = "require" - } - return fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s sslmode=%s", - host, port, db, user, pass, sslmode) -} - -// worker — одна горутина: чередует INSERT/COUNT/MAX с случайной задержкой до maxDelayMs. -// При ошибке инкрементирует errOps и продолжает (не паникует). -func worker(ctx context.Context, pool *pgxpool.Pool, workerID int, maxDelayMs int, okOps, errOps *int64) { - rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(workerID))) - op := 0 - for { - select { - case <-ctx.Done(): - return - default: - } - - // Случайная задержка перед следующей операцией: 0..maxDelayMs мс - delay := rng.Intn(maxDelayMs + 1) - time.Sleep(time.Duration(delay) * time.Millisecond) - - var err error - switch op % 3 { - case 0: // INSERT - title := fmt.Sprintf("pgstorm-w%d-%d", workerID, time.Now().UnixNano()) - _, err = pool.Exec(ctx, - "INSERT INTO terraform_demo_table (title) VALUES ($1)", title) - case 1: // SELECT COUNT - var count int64 - err = pool.QueryRow(ctx, - "SELECT COUNT(*) FROM terraform_demo_table").Scan(&count) - case 2: // SELECT MAX id - var maxID *int64 - err = pool.QueryRow(ctx, - "SELECT MAX(id) FROM terraform_demo_table").Scan(&maxID) - } - - if err != nil && ctx.Err() == nil { - atomic.AddInt64(errOps, 1) - } else if err == nil { - atomic.AddInt64(okOps, 1) - } - op++ - } -} - -func Handle(event map[string]interface{}) interface{} { - // Параметры из event (все опциональны — разумные defaults) - workers := 100 - if v, ok := event["workers"].(float64); ok && v > 0 && v <= 500 { - workers = int(v) - } - durationSec := 600 - if v, ok := event["duration_sec"].(float64); ok && v > 0 && v <= 3600 { - durationSec = int(v) - } - maxDelayMs := 300 - if v, ok := event["max_delay_ms"].(float64); ok && v >= 0 && v <= 5000 { - maxDelayMs = int(v) - } - - // Инициализация pgxpool — единый pool на всю функцию, MaxConns ограничен - // чтобы не перегрузить managed PG при большом числе горутин. - poolCfg, err := pgxpool.ParseConfig(pgDSN()) - if err != nil { - return map[string]interface{}{"error": fmt.Sprintf("parse dsn: %v", err)} - } - maxConns := 20 - if workers < 20 { - maxConns = workers - } - poolCfg.MaxConns = int32(maxConns) - - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(durationSec)*time.Second) - defer cancel() - - pool, err := pgxpool.NewWithConfig(ctx, poolCfg) - if err != nil { - return map[string]interface{}{"error": fmt.Sprintf("connect pool: %v", err)} - } - defer pool.Close() - - var okOps, errOps int64 - startTime := time.Now() - - var wg sync.WaitGroup - for i := 0; i < workers; i++ { - wg.Add(1) - go func(id int) { - defer wg.Done() - worker(ctx, pool, id, maxDelayMs, &okOps, &errOps) - }(i) - } - wg.Wait() - - elapsed := time.Since(startTime).Seconds() - total := okOps + errOps - opsPerSec := 0.0 - if elapsed > 0 { - opsPerSec = float64(total) / elapsed - } - - return map[string]interface{}{ - "runtime": "go1.23", - "version": "v1", - "workers": workers, - "duration_sec": durationSec, - "max_delay_ms": maxDelayMs, - "elapsed_sec": fmt.Sprintf("%.1f", elapsed), - "total_ops": total, - "ok_ops": okOps, - "err_ops": errOps, - "ops_per_sec": fmt.Sprintf("%.1f", opsPerSec), - } -} diff --git a/POSTGRES/code/stress-js-async/package.json b/POSTGRES/code/stress-js-async/package.json deleted file mode 100644 index 554d7bc..0000000 --- a/POSTGRES/code/stress-js-async/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "stress-js-async", - "version": "1.0.0", - "dependencies": { - "pg": "^8.11.0" - } -} diff --git a/POSTGRES/code/stress-js-async/stress_js_async.js b/POSTGRES/code/stress-js-async/stress_js_async.js deleted file mode 100644 index 022c2b3..0000000 --- a/POSTGRES/code/stress-js-async/stress_js_async.js +++ /dev/null @@ -1,37 +0,0 @@ -// 2026-03-19 -// stress_js_async.js — делает 3 параллельных запроса к PG через Promise.all. -// Проверяет nodejs20 runtime под умеренной нагрузкой и async/await. -// -// Entrypoint: stress_js_async.run - -'use strict'; - -const { Client } = require('pg'); - -exports.run = async (event) => { - const client = new Client({ - host: process.env.PGHOST, - port: parseInt(process.env.PGPORT || '5432'), - database: process.env.PGDATABASE, - user: process.env.PGUSER, - password: process.env.PGPASSWORD, - ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : false, - }); - await client.connect(); - try { - const [ver, cnt, max] = await Promise.all([ - client.query('SELECT version() AS v'), - client.query('SELECT COUNT(*) AS cnt FROM terraform_demo_table'), - client.query('SELECT MAX(id) AS max_id FROM terraform_demo_table'), - ]); - return { - runtime: 'nodejs20', - version: 'v1', - pg_version: ver.rows[0].v.split(' ').slice(0, 2).join(' '), - total_rows: parseInt(cnt.rows[0].cnt, 10), - max_id: max.rows[0].max_id, - }; - } finally { - await client.end(); - } -}; diff --git a/POSTGRES/code/stress-js-badenv/package.json b/POSTGRES/code/stress-js-badenv/package.json deleted file mode 100644 index 1b3892c..0000000 --- a/POSTGRES/code/stress-js-badenv/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "stress-js-badenv", - "version": "1.0.0", - "dependencies": {} -} diff --git a/POSTGRES/code/stress-js-badenv/stress_js_badenv.js b/POSTGRES/code/stress-js-badenv/stress_js_badenv.js deleted file mode 100644 index 7169fd8..0000000 --- a/POSTGRES/code/stress-js-badenv/stress_js_badenv.js +++ /dev/null @@ -1,17 +0,0 @@ -// 2026-03-19 -// stress_js_badenv.js — читает несуществующую переменную env и падает. -// Проверяет: платформа перехватывает TypeError/undefined, возвращает 500. -// -// Entrypoint: stress_js_badenv.run - -'use strict'; - -exports.run = async (event) => { - const crash = event.crash !== false; // по умолчанию crash=true - if (crash) { - // Читаем несуществующий env, пытаемся вызвать .toUpperCase() на undefined - const val = process.env.THIS_VAR_DOES_NOT_EXIST_AT_ALL; - return { shout: val.toUpperCase() }; // TypeError: Cannot read properties of undefined - } - return { runtime: 'nodejs20', version: 'v1', crashed: false }; -}; diff --git a/POSTGRES/code/stress-slow/requirements.txt b/POSTGRES/code/stress-slow/requirements.txt deleted file mode 100644 index e69de29..0000000 diff --git a/POSTGRES/code/stress-slow/stress_slow.py b/POSTGRES/code/stress-slow/stress_slow.py deleted file mode 100644 index ba54397..0000000 --- a/POSTGRES/code/stress-slow/stress_slow.py +++ /dev/null @@ -1,18 +0,0 @@ -# 2026-03-19 -# stress_slow.py — долгая функция: спит N секунд (по умолчанию 8). -# Проверяет что timeout-механизм и параллельные запросы не блокируют друг друга. - -import time -import os - -_VERSION = "v1" - - -def run(event): - secs = int(event.get("sleep", 8)) - time.sleep(secs) - return { - "version": _VERSION, - "slept_sec": secs, - "pid": os.getpid(), - } diff --git a/POSTGRES/code/stress-writer/requirements.txt b/POSTGRES/code/stress-writer/requirements.txt deleted file mode 100644 index 58ab769..0000000 --- a/POSTGRES/code/stress-writer/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -psycopg2-binary==2.9.9 diff --git a/POSTGRES/code/stress-writer/stress_writer.py b/POSTGRES/code/stress-writer/stress_writer.py deleted file mode 100644 index 7eda871..0000000 --- a/POSTGRES/code/stress-writer/stress_writer.py +++ /dev/null @@ -1,39 +0,0 @@ -# 2026-03-19 -# stress_writer.py — пишет N строк в terraform_demo_table (по умолчанию 5). -# Проверяет параллельные INSERT'ы и устойчивость соединения с PG при нагрузке. - -import os -import psycopg2 -import time - -_VERSION = "v1" - - -def run(event): - n = int(event.get("rows", 5)) - prefix = event.get("prefix", "stress") - - conn = psycopg2.connect( - host=os.environ["PGHOST"], - port=int(os.environ.get("PGPORT", "5432")), - dbname=os.environ["PGDATABASE"], - user=os.environ["PGUSER"], - password=os.environ["PGPASSWORD"], - sslmode=os.environ.get("PGSSLMODE", "require"), - ) - inserted = [] - try: - with conn.cursor() as cur: - for i in range(n): - title = f"{prefix}-{int(time.time()*1000)}-{i}" - cur.execute( - "INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id", - (title,), - ) - row = cur.fetchone() - inserted.append({"id": row[0], "title": title}) - conn.commit() - finally: - conn.close() - - return {"version": _VERSION, "inserted": inserted, "count": len(inserted)} diff --git a/POSTGRES/code/table-rw/requirements.txt b/POSTGRES/code/table-rw/requirements.txt deleted file mode 100644 index 58ab769..0000000 --- a/POSTGRES/code/table-rw/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -psycopg2-binary==2.9.9 diff --git a/POSTGRES/code/table-rw/table_rw.py b/POSTGRES/code/table-rw/table_rw.py deleted file mode 100644 index d142e34..0000000 --- a/POSTGRES/code/table-rw/table_rw.py +++ /dev/null @@ -1,133 +0,0 @@ -# 2026-03-19 — добавлен version и hostname в ответ list_rows для тестирования обновления кода -# table_rw.py — чтение и запись строк в terraform_demo_table. -# Два entrypoint в одном файле: list_rows (JSON API) и add_row (HTML-страница + POST-обработчик). -# ENV: PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE - -import os -import json -import socket -import psycopg2 -import psycopg2.extras - -_CODE_VERSION = "v2-with-hostname" - - -def _connect(): - return psycopg2.connect( - host=os.environ["PGHOST"], - port=os.environ.get("PGPORT", "5432"), - dbname=os.environ["PGDATABASE"], - user=os.environ["PGUSER"], - password=os.environ["PGPASSWORD"], - sslmode=os.environ.get("PGSSLMODE", "require"), - ) - - -def list_rows(event): - # Возвращает все строки terraform_demo_table, отсортированные по убыванию created_at. - conn = _connect() - try: - cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) - cur.execute( - "SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC" - ) - rows = [dict(r) for r in cur.fetchall()] - return {"rows": rows, "count": len(rows), "version": _CODE_VERSION, "host": socket.gethostname()} - finally: - conn.close() - - -def _render_page(rows, message=""): - # HTML-страница с формой ввода и таблицей строк. - # message — статус последней операции (успех / ошибка). - rows_html = "".join( - f"{r['id']}{r['title']}{r['created_at']}" - for r in rows - ) - msg_html = f'

{message}

' if message else "" - return f""" - - - - pg-table-writer - - - -

pg-table-writer

-
- - -
- {msg_html} - - - {rows_html} -
#titlecreated_at
- -""" - - -def add_row(event): - # GET → HTML-страница с формой и списком строк. - # POST → вставляет строку из form-поля title или JSON-поля title, - # затем возвращает обновлённую HTML-страницу. - # POST с Content-Type: application/json (curl/API) → возвращает JSON. - method = event.get("_method", "GET") - - if method == "GET": - conn = _connect() - try: - cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) - cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC") - rows = [dict(r) for r in cur.fetchall()] - finally: - conn.close() - return _render_page(rows) - - # POST — вставка строки - # Поле title приходит либо из JSON-тела, либо из application/x-www-form-urlencoded. - # Сервер уже распарсил JSON в event; form-данные приходят как event["body"] = "title=...". - title = event.get("title", "").strip() - if not title: - # Попытка распарсить form-encoded body (браузерная форма) - body = event.get("body", "") - if body.startswith("title="): - from urllib.parse import unquote_plus - title = unquote_plus(body[len("title="):].split("&")[0]).strip() - - if not title: - return {"ok": False, "error": "title is required"} - - conn = _connect() - try: - cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) - cur.execute( - "INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id, title, created_at::text", - (title,), - ) - row = dict(cur.fetchone()) - conn.commit() - - # Если запрос из браузера (form POST) — возвращаем обновлённую страницу. - # Если из curl/API — возвращаем JSON. - accept = event.get("_accept", "") - if "application/json" in accept: - return {"ok": True, "row": row} - - # Перечитываем все строки для обновлённой страницы - cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC") - rows = [dict(r) for r in cur.fetchall()] - return _render_page(rows, message=f"Добавлено: «{row['title']}»") - finally: - conn.close() diff --git a/POSTGRES/functions.tf b/POSTGRES/functions.tf index e2f553c..89263cf 100644 --- a/POSTGRES/functions.tf +++ b/POSTGRES/functions.tf @@ -1,105 +1,36 @@ -// 2026-03-20 (merge: sless_function + старый sless_job объединены в один self-contained sless_job) -// Теперь sless_job несёт в себе runtime/entrypoint/source_dir — не нужен отдельный sless_function. -// WaitJobDone таймаут 900s покрывает kaniko сборку (~5 мин) + выполнение SQL (~несколько сек). +# Создано: 2026-04-10 +# functions.tf — sless_service ресурсы для примера POSTGRES. +# Здесь: два калькуляторa — Python и Node.js. +# sless_service = long-running Deployment + постоянный URL (в отличие от sless_function). -# Одноразовый запуск: собирает образ через kaniko, выполняет SQL, завершается. -# Заменяет sless_function.postgres_sql_runner_create_table + sless_job.postgres_table_init_job. -resource "sless_job" "postgres_table_init_job" { - name = "pg-create-table-job-main-v13" - runtime = "python3.11" - entrypoint = "sql_runner.run_sql" - memory_mb = 128 - timeout_sec = 30 - source_dir = "${path.module}/code/sql-runner" - wait_timeout_sec = 900 - run_id = 13 +# ─── Python-калькулятор ────────────────────────────────────────────────────── - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - event_json = jsonencode({ - statements = [ - "CREATE TABLE IF NOT EXISTS terraform_demo_table (id serial PRIMARY KEY, title text NOT NULL, created_at timestamp DEFAULT now())" - ] - }) - - depends_on = [nubes_postgres_database.db] -} - -# Long-running сервис на NodeJS: возвращает версию PG-сервера и счётчик строк в таблице. -resource "sless_service" "pg_info" { - name = "pg-info" - runtime = "nodejs20" - entrypoint = "pg_info.info" - memory_mb = 128 - timeout_sec = 15 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/pg-info" - - depends_on = [sless_job.postgres_table_init_job] -} - -resource "sless_service" "postgres_table_reader" { - name = "pg-table-reader" - runtime = "python3.11" - entrypoint = "table_rw.list_rows" +resource "sless_service" "calc_python" { + name = "calc-python" + runtime = "python3.11" + entrypoint = "handler.handler" memory_mb = 128 timeout_sec = 30 - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/table-rw" - - depends_on = [sless_job.postgres_table_init_job] + source_dir = "${path.module}/code/calc-python" } -output "table_reader_url" { - value = sless_service.postgres_table_reader.url +output "calc_python_url" { + description = "URL Python-калькулятора" + value = sless_service.calc_python.url } -resource "sless_service" "postgres_table_writer" { - name = "pg-table-writer" - runtime = "python3.11" - entrypoint = "table_rw.add_row" - memory_mb = 256 - timeout_sec = 45 +# ─── Node.js-калькулятор ───────────────────────────────────────────────────── - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - source_dir = "${path.module}/code/table-rw" - - depends_on = [sless_job.postgres_table_init_job] +resource "sless_service" "calc_node" { + name = "calc-node" + runtime = "nodejs20" + entrypoint = "handler.handler" + memory_mb = 128 + timeout_sec = 30 + source_dir = "${path.module}/code/calc-node" } -output "table_writer_url" { - value = sless_service.postgres_table_writer.url +output "calc_node_url" { + description = "URL Node.js-калькулятора" + value = sless_service.calc_node.url } diff --git a/POSTGRES/main.tf b/POSTGRES/main.tf index 26cb6d7..834387f 100644 --- a/POSTGRES/main.tf +++ b/POSTGRES/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { nubes = { source = "terra.k8c.ru/nubes/nubes" - version = "5.0.19" + version = "5.0.31" } sless = { source = "terra.k8c.ru/naeel/sless" diff --git a/POSTGRES/stress.tf b/POSTGRES/stress.tf deleted file mode 100644 index b31d449..0000000 --- a/POSTGRES/stress.tf +++ /dev/null @@ -1,167 +0,0 @@ -// 2026-03-21 — stress.tf: все стресс-сервисы для комплексного тестирования. -// Три рантайма: go1.23 (3), nodejs20 (2), python3.11 (5). -// Все depends_on = [sless_job.postgres_table_init_job] — таблица должна существовать. - -# ── Go 1.23 ───────────────────────────────────────────────────────────────── - -# Быстрая математика: факториал + числа Фибоначчи. Без PG. Проверяет Go runtime. -resource "sless_service" "stress_go_fast" { - name = "stress-go-fast" - runtime = "go1.23" - entrypoint = "handler.Handle" - memory_mb = 128 - timeout_sec = 15 - source_dir = "${path.module}/code/stress-go-fast" - - depends_on = [sless_job.postgres_table_init_job] -} - -# Намеренный nil pointer dereference. Без PG. Проверяет recover() в Go runtime. -resource "sless_service" "stress_go_nil" { - name = "stress-go-nil" - runtime = "go1.23" - entrypoint = "handler.Handle" - memory_mb = 128 - timeout_sec = 10 - source_dir = "${path.module}/code/stress-go-nil" - - depends_on = [sless_job.postgres_table_init_job] -} - -# Конкурентный PG-шторм через pgxpool: N горутин INSERT/COUNT/MAX. -# timeout_sec=660 — покрывает max duration_sec=600 с запасом. -# pgx/v5 уже в базовом образе go1.23: user go.mod не нужен. -resource "sless_service" "stress_go_pgstorm" { - name = "stress-go-pgstorm" - runtime = "go1.23" - entrypoint = "handler.Handle" - memory_mb = 256 - timeout_sec = 660 - source_dir = "${path.module}/code/stress-go-pgstorm" - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - depends_on = [sless_job.postgres_table_init_job] -} - -# ── Node.js 20 ──────────────────────────────────────────────────────────────── - -# 3 параллельных PG-запроса через Promise.all. Проверяет async/await + nodejs20. -resource "sless_service" "stress_js_async" { - name = "stress-js-async" - runtime = "nodejs20" - entrypoint = "stress_js_async.run" - memory_mb = 128 - timeout_sec = 20 - source_dir = "${path.module}/code/stress-js-async" - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - depends_on = [sless_job.postgres_table_init_job] -} - -# TypeError через undefined.toUpperCase(). Без PG. Проверяет перехват JS-ошибок. -resource "sless_service" "stress_js_badenv" { - name = "stress-js-badenv" - runtime = "nodejs20" - entrypoint = "stress_js_badenv.run" - memory_mb = 128 - timeout_sec = 10 - source_dir = "${path.module}/code/stress-js-badenv" - - depends_on = [sless_job.postgres_table_init_job] -} - -# ── Python 3.11 ─────────────────────────────────────────────────────────────── - -# Спит N секунд. Без PG. Проверяет timeout и сосуществование долгих запросов. -resource "sless_service" "stress_slow" { - name = "stress-slow" - runtime = "python3.11" - entrypoint = "stress_slow.run" - memory_mb = 128 - timeout_sec = 35 - source_dir = "${path.module}/code/stress-slow" - - depends_on = [sless_job.postgres_table_init_job] -} - -# CPU-нагрузка: сумма квадратов N чисел. Без PG. Проверяет compute-bound задачи. -resource "sless_service" "stress_bigloop" { - name = "stress-bigloop" - runtime = "python3.11" - entrypoint = "stress_bigloop.run" - memory_mb = 256 - timeout_sec = 60 - source_dir = "${path.module}/code/stress-bigloop" - - depends_on = [sless_job.postgres_table_init_job] -} - -# ZeroDivisionError. Без PG. Проверяет перехват Python-исключений → HTTP 500. -resource "sless_service" "stress_divzero" { - name = "stress-divzero" - runtime = "python3.11" - entrypoint = "stress_divzero.run" - memory_mb = 128 - timeout_sec = 10 - source_dir = "${path.module}/code/stress-divzero" - - depends_on = [sless_job.postgres_table_init_job] -} - -# Параллельный INSERT в terraform_demo_table через psycopg2. Проверяет PG-write. -resource "sless_service" "stress_writer" { - name = "stress-writer" - runtime = "python3.11" - entrypoint = "stress_writer.run" - memory_mb = 128 - timeout_sec = 60 - source_dir = "${path.module}/code/stress-writer" - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - depends_on = [sless_job.postgres_table_init_job] -} - -# Агрегированная статистика terraform_demo_table (COUNT/MIN/MAX). Для мониторинга. -resource "sless_service" "pg_stats" { - name = "pg-stats" - runtime = "python3.11" - entrypoint = "pg_stats.get_stats" - memory_mb = 128 - timeout_sec = 15 - source_dir = "${path.module}/code/pg-stats" - - env_vars = { - PGHOST = local.pg_host - PGPORT = "5432" - PGDATABASE = local.pg_database - PGUSER = local.pg_username - PGPASSWORD = local.pg_password - PGSSLMODE = "require" - } - - depends_on = [sless_job.postgres_table_init_job] -} diff --git a/POSTGRES/test_cache_matrix.sh b/POSTGRES/test_cache_matrix.sh new file mode 100755 index 0000000..10d9ddb --- /dev/null +++ b/POSTGRES/test_cache_matrix.sh @@ -0,0 +1,176 @@ +#!/bin/bash +# test_cache_matrix.sh — 2026-03-23 (v4) +# Комплексный тест кэша registry: +# Phase 1 — полный деплой всех 24 ресурсов (kaniko builds, т.к. нет образов) +# Phase 2 — destroy sless_* + re-apply (все образы из кэша) +# Phase 3 — одновременно: удаление 2, смена кода 2, смена параметров 2 +# ВАЖНО: postgres.tf НЕ переименовывается и НЕ трогается никогда. +# Destroy sless-ресурсов делается путём переименования tf-файлов в .tf.bak, +# затем terraform apply (видит что ресурсов нет → удаляет их из state+кластера), +# затем файлы возвращаются обратно. Никаких -target. + +set -euo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +LOG="$DIR/test_cache_matrix_$(date +%Y%m%d_%H%M%S).log" +TIMINGS="$LOG.timings" +PASS=0 +FAIL=0 + +log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG"; } +sep() { log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"; } + +timed_op() { + local label="$1"; shift + log "▶ START: $label" + local t0; t0=$(date +%s%3N) + "$@" 2>&1 | tee -a "$LOG" + local rc=${PIPESTATUS[0]} + local t1; t1=$(date +%s%3N) + local elapsed=$(( (t1 - t0) / 1000 )) + if [[ $rc -eq 0 ]]; then + log "✓ DONE: $label — ${elapsed}s" + PASS=$((PASS+1)) + else + log "✗ FAIL: $label — ${elapsed}s (exit $rc)" + FAIL=$((FAIL+1)) + fi + echo "$label: ${elapsed}s" >> "$TIMINGS" + return $rc +} + +destroy_sless_only() { + # Переименовываем tf-файлы с sless-ресурсами в .tf.bak → terraform apply их удалит. + # Никаких -target — чтобы не затрагивать postgres и не получать state drift. + local label="$1" + local SLESS_FILES=("chaos_marathon.tf" "functions.tf" "stress.tf") + + local has_state + has_state=$(terraform state list 2>/dev/null | grep -cE '^(sless_service|sless_job)' || true) + if [[ "$has_state" -eq 0 ]]; then + log " (nothing to destroy for $label — state empty)" + return 0 + fi + log " Hiding sless tf-files → apply will destroy $has_state resources" + + for f in "${SLESS_FILES[@]}"; do + [[ -f "$DIR/$f" ]] && mv "$DIR/$f" "$DIR/$f.bak" + done + + timed_op "$label" terraform apply -auto-approve + + for f in "${SLESS_FILES[@]}"; do + [[ -f "$DIR/$f.bak" ]] && mv "$DIR/$f.bak" "$DIR/$f" + done + log " sless tf-files restored" +} + +cd "$DIR" + +sep +log "PHASE 1: Полный начальный деплой" +sep +destroy_sless_only "phase1-pre-clean" +timed_op "phase1-apply-all" terraform apply -auto-approve + +log "--- Образы в registry после Phase 1 ---" +kubectl exec -n sless deployment/sless-registry -- sh -c 'find /var/lib/registry -name "*.json" -path "*/tags/*" 2>/dev/null | sed "s|.*repository/||;s|/_manifests.*||" | sort | uniq -c | sort -rn' 2>/dev/null | head -30 | tee -a "$LOG" || log "(registry inspect failed)" + +sep +log "PHASE 2: Destroy sless_* → Re-apply (ожидаем cache hits)" +sep +destroy_sless_only "phase2-destroy" +timed_op "phase2-apply-cached" terraform apply -auto-approve + +sep +log "PHASE 3: Mixed ops (delete+code+params)" +sep + +log "--- 3a: destroy stress_divzero, chaos_echo (comment out → apply → restore) ---" +python3 - <<'PYEOF' +import re, pathlib + +def comment_out_resource(path, resource_type, resource_name): + text = pathlib.Path(path).read_text() + # Находим блок resource "type" "name" { ... } и оборачиваем в /* */ + pattern = rf'(resource\s+"{re.escape(resource_type)}"\s+"{re.escape(resource_name)}"\s*\{{)' + match = re.search(pattern, text) + if not match: + print(f" WARNING: {resource_type}.{resource_name} not found in {path}") + return + # Найти закрывающую скобку блока + start = match.start() + depth = 0 + i = match.start() + while i < len(text): + if text[i] == '{': depth += 1 + elif text[i] == '}': + depth -= 1 + if depth == 0: + end = i + 1 + break + i += 1 + block = text[start:end] + commented = "/* COMMENTED_OUT_FOR_TEST\n" + block + "\nCOMMENTED_OUT_FOR_TEST */" + pathlib.Path(path).write_text(text[:start] + commented + text[end:]) + print(f" commented out: {resource_type}.{resource_name} in {path}") + +comment_out_resource("stress.tf", "sless_service", "stress_divzero") +comment_out_resource("chaos_marathon.tf", "sless_service", "chaos_echo") +PYEOF +timed_op "phase3a-destroy-2" terraform apply -auto-approve +# Восстанавливаем закомментированные блоки +python3 - <<'PYEOF' +import pathlib, re + +for fname in ("stress.tf", "chaos_marathon.tf"): + p = pathlib.Path(fname) + text = p.read_text() + text = re.sub(r'/\* COMMENTED_OUT_FOR_TEST\n', '', text) + text = re.sub(r'\nCOMMENTED_OUT_FOR_TEST \*/', '', text) + p.write_text(text) + print(f" restored: {fname}") +PYEOF +log " stress_divzero, chaos_echo removed from state and k8s" + +log "--- 3b: code changes (new sha256 → kaniko) ---" +echo "" >> "$DIR/code/pg-counter/pg_counter.py" +echo "# cache-test-$(date +%s)" >> "$DIR/code/pg-counter/pg_counter.py" +echo "" >> "$DIR/code/stress-js-async/stress_js_async.js" +echo "// cache-test-$(date +%s)" >> "$DIR/code/stress-js-async/stress_js_async.js" +log " changed: pg_counter.py, stress_js_async.js" + +log "--- 3c: param changes (same sha256 → no kaniko) ---" +python3 - <<'PYEOF' +import re, sys +with open("stress.tf") as f: + content = f.read() +orig = content +content = re.sub( + r'(resource "sless_service" "stress_slow" \{[^}]*?)memory_mb\s*=\s*\d+', + lambda m: m.group(1) + 'memory_mb = 192', + content, flags=re.DOTALL +) +content = re.sub( + r'(resource "sless_service" "pg_stats" \{[^}]*?)timeout_sec\s*=\s*\d+', + lambda m: m.group(1) + 'timeout_sec = 20', + content, flags=re.DOTALL +) +if content == orig: + print(" stress.tf: no changes (already patched?)", file=sys.stderr) +else: + with open("stress.tf", "w") as f: + f.write(content) + print(" stress.tf: stress_slow→memory_mb=192, pg_stats→timeout_sec=20") +PYEOF + +log "--- 3d: apply всех mixed изменений ---" +log " Expected: stress_divzero+chaos_echo=cache_hit, pg_counter+stress_js_async=kaniko, stress_slow+pg_stats=k8s_only" +timed_op "phase3d-mixed-apply" terraform apply -auto-approve + +sep +log "ИТОГ" +sep +log "Timings:" +cat "$TIMINGS" 2>/dev/null | tee -a "$LOG" +log "Pass: $PASS | Fail: $FAIL" +log "Лог: $LOG" diff --git a/VM/.vm_stress_test.sh.OLD b/VM/.vm_stress_test.sh.OLD new file mode 100755 index 0000000..8047a31 --- /dev/null +++ b/VM/.vm_stress_test.sh.OLD @@ -0,0 +1,852 @@ +#!/usr/bin/env bash +# 2026-03-30 — vm_stress_test.sh +# Автономный stress/chaos тест для examples/VM. +# +# ЦЕЛЬ: прогнать полную матрицу сценариев lifecycle VM + sless_job без +# вмешательства человека. Скрипт самовосстанавливается при ошибках +# и продолжает следующую фазу. +# +# ФАЗЫ: +# 1 BASELINE — apply с полным набором (packages + nginx + docker) +# 2 IDEMPOTENT — повторный apply → "No changes" +# 3 PARTIAL_DISABLE — выключить nginx + docker → apply (2 job уничтожаются) +# 4 PARTIAL_ENABLE — включить обратно → apply (2 job создаются) +# 5 REORDER_PACKAGES — изменить порядок и состав base_packages +# 6 MANUAL_PURGE — удалить все пакеты с VM по SSH → re-run jobs +# 7 DESTROY — terraform destroy → VM уходит в suspend +# 8 RESURRECT — apply после destroy → VM просыпается, всё ставится +# 9 STRESS_CYCLES — N подряд destroy/apply циклов +# 10 FINAL_SANITY — проверить доступность VM и установленные пакеты +# +# ЗАПУСК: +# bash ~/terra/sless/examples/VM/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 (для быстрого прогона) +# +# ТРЕБОВАНИЯ: terraform, ssh, python3 — всё доступно на VM naeel@5.172.178.213 +# +# ДЛЯ БЕСПЛАТНЫХ AI-АГЕНТОВ: +# 1. Подключиться по SSH к VM +# 2. Запустить: bash ~/terra/sless/examples/VM/vm_stress_test.sh 2>&1 | tee /tmp/vm_stress.log +# 3. Дождаться завершения (exit code 0 = все тесты прошли) +# 4. Проанализировать вывод: grep -E '\[(PASS|FAIL|SKIP)\]' /tmp/vm_stress.log +# 5. Итоговая сводка печатается в конце лога + +set -uo pipefail + +# ── CONFIG ──────────────────────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +VM_KEY="$SCRIPT_DIR/vm_key" +TFVARS="$SCRIPT_DIR/terraform.tfvars" +TFVARS_BACKUP="$SCRIPT_DIR/.terraform.tfvars.test-backup" +STRESS_CYCLES="${STRESS_CYCLES:-2}" +SKIP_DESTROY="${SKIP_DESTROY:-0}" + +# ── СТАТИСТИКА ──────────────────────────────────────────────────────────────── + +PASS=0; FAIL=0; SKIP=0 +PHASE_RESULTS=() # "PHASE_NAME:PASS|FAIL|SKIP" +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 ───────────────────────────────────────────────────────── + +# tf_apply: terraform apply с retry при сетевых ошибках. +# Возвращает 0 при успехе, 1 при ошибке. +tf_apply() { + local attempt=1 max=3 + while [[ $attempt -le $max ]]; do + info "terraform apply (попытка $attempt/$max)..." + # Явное указание var-file и input=false чтобы избежать запросов в терминале + if terraform apply -var-file="terraform.tfvars" -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: terraform destroy с retry. +tf_destroy() { + local attempt=1 max=3 + while [[ $attempt -le $max ]]; do + info "terraform destroy (попытка $attempt/$max)..." + # Добавляем -var-file и -input=false сюда тоже + if terraform destroy -var-file="terraform.tfvars" -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 не содержит изменений. +# Возвращает 0 если "No changes", 1 если есть изменения. +tf_plan_no_changes() { + # Добавляем -var-file и -input=false чтобы не было запросов в терминал + terraform plan -var-file="terraform.tfvars" -input=false -no-color 2>&1 | tee /tmp/vm_tf_plan.log + if grep -q 'No changes' /tmp/vm_tf_plan.log; then + return 0 + fi + return 1 +} + +# tf_state_count: количество ресурсов в state. +tf_state_count() { + terraform state list 2>/dev/null | wc -l +} + +# ── TFVARS HELPERS ──────────────────────────────────────────────────────────── + +# Сохранять/восстанавливать tfvars для самовосстановления. +backup_tfvars() { + if [[ ! -f "$TFVARS_BACKUP" ]]; then + cp "$TFVARS" "$TFVARS_BACKUP" + info "Создан бэкап tfvars: $TFVARS_BACKUP" + fi +} +restore_tfvars() { + if [[ -f "$TFVARS_BACKUP" ]]; then + # Читаем токен и ключ из бэкапа перед перезаписью + local token key + token=$(grep 'api_token' "$TFVARS_BACKUP" | cut -d'"' -f2) + key=$(grep 'vm_public_key' "$TFVARS_BACKUP" | cut -d'"' -f2) + + # Если в бэкапе пусто (такое бывает при сбоях сохранения), не портим оригинал + if [[ -n "$token" && -n "$key" ]]; then + cp "$TFVARS_BACKUP" "$TFVARS" + info "tfvars восстановлен из бэкапа" + else + warn "Бэкап tfvars поврежден или пуст, восстановление пропущено" + fi + fi +} + +# write_tfvars: перезаписать tfvars программно. +# Аргументы: install_packages install_nginx install_docker run_id base_packages_json +write_tfvars() { + local pkgs="$1" nginx="$2" docker="$3" run_id="$4" base_pkgs="$5" + + # Читаем токен напрямую из файла secrets если в бэкапе пусто + local token key + token=$(grep 'api_token' "$TFVARS_BACKUP" 2>/dev/null | cut -d'=' -f2- | cut -d'#' -f1 | xargs | sed 's/^"//;s/"$//') + if [[ -z "$token" ]]; then + info "Токен не найден в бэкапе, берем из ~/terra/sless/secrets/tazetnarodtest.token" + token=$(ssh -i "$VM_KEY" -o StrictHostKeyChecking=no -o ConnectTimeout=5 "ubuntu@185.247.187.154" "cat ~/terra/sless/secrets/tazetnarodtest.token" 2>/dev/null || cat "$SCRIPT_DIR/../../secrets/tazetnarodtest.token" 2>/dev/null) + fi + + key=$(grep 'vm_public_key' "$TFVARS_BACKUP" 2>/dev/null | cut -d'=' -f2- | cut -d'#' -f1 | xargs | sed 's/^"//;s/"$//') + if [[ -z "$key" ]]; then + key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKQB+Kyevn0H8QSdfm6ZpCU9jtskBxUS9BV0+i1/M04A sless-demo-vm" + fi + + cat > "$TFVARS" </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 +} + +# ── SELF-RECOVERY ───────────────────────────────────────────────────────────── + +# ensure_baseline: гарантировать что VM и все 3 job в state. +# Используется для восстановления после сбоя фазы. +ensure_baseline() { + info "восстанавливаю baseline state..." + restore_tfvars + # Перезаписать tfvars на полный набор с текущим run_id + local run_id + run_id=$(grep 'install_run_id' "$TFVARS_BACKUP" | grep -oP '\d+' | head -1) + write_tfvars "true" "true" "true" "$run_id" '["jq", "python3-pip", "htop", "unzip"]' + tf_apply || warn "baseline apply не удался — продолжаю" +} + +# ══════════════════════════════════════════════════════════════════════════════ +# ФАЗА 1: BASELINE — apply с полным набором +# ══════════════════════════════════════════════════════════════════════════════ + +phase_1_baseline() { + phase_header 1 "BASELINE — apply с полным набором" + + # Сохранить оригинальный tfvars + backup_tfvars + + # Читаем текущий run_id и инкрементируем + local run_id + run_id=$(grep 'install_run_id' "$TFVARS" | grep -oP '\d+' | head -1) + run_id=$((run_id + 1)) + + write_tfvars "true" "true" "true" "$run_id" '["jq", "python3-pip", "htop", "unzip"]' + + if tf_apply; 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 + + # Обновить бэкап с новым run_id + backup_tfvars + + phase_result "BASELINE" "PASS" +} + +# ══════════════════════════════════════════════════════════════════════════════ +# ФАЗА 2: IDEMPOTENT — повторный apply без изменений +# ══════════════════════════════════════════════════════════════════════════════ + +phase_2_idempotent() { + phase_header 2 "IDEMPOTENT — повторный apply без изменений" + + if tf_plan_no_changes; 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 +# ══════════════════════════════════════════════════════════════════════════════ + +phase_3_partial_disable() { + phase_header 3 "PARTIAL_DISABLE — выключить nginx + docker" + + local run_id + run_id=$(grep 'install_run_id' "$TFVARS" | grep -oP '\d+' | head -1) + + write_tfvars "true" "false" "false" "$run_id" '["jq", "python3-pip", "htop", "unzip"]' + + if tf_apply; then + pass "3.1 apply с partial disable завершился" + else + fail "3.1 apply с partial disable упал" + restore_tfvars + 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 — включить обратно всё +# ══════════════════════════════════════════════════════════════════════════════ + +phase_4_partial_enable() { + phase_header 4 "PARTIAL_ENABLE — включить обратно всё" + + local run_id + run_id=$(grep 'install_run_id' "$TFVARS" | grep -oP '\d+' | head -1) + run_id=$((run_id + 1)) + + write_tfvars "true" "true" "true" "$run_id" '["jq", "python3-pip", "htop", "unzip"]' + + if tf_apply; 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 + + # Обновить бэкап + backup_tfvars + + phase_result "PARTIAL_ENABLE" "PASS" +} + +# ══════════════════════════════════════════════════════════════════════════════ +# ФАЗА 5: REORDER_PACKAGES — изменить порядок и состав base_packages +# ══════════════════════════════════════════════════════════════════════════════ + +phase_5_reorder() { + phase_header 5 "REORDER_PACKAGES — изменить порядок и состав пакетов" + + local run_id + run_id=$(grep 'install_run_id' "$TFVARS" | grep -oP '\d+' | head -1) + run_id=$((run_id + 1)) + + # Другой порядок и сокращённый набор + write_tfvars "true" "true" "true" "$run_id" '["htop", "jq"]' + + if tf_apply; then + pass "5.1 apply с изменённым набором пакетов завершился" + else + fail "5.1 apply с изменённым набором пакетов упал" + restore_tfvars + 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 -q '"status"'; then + pass "5.2 install_packages вернул JSON с status" + else + fail "5.2 install_packages output неожиданный: $pkg_out" + fi + + # Вернуть полный набор + run_id=$((run_id + 1)) + write_tfvars "true" "true" "true" "$run_id" '["jq", "python3-pip", "htop", "unzip"]' + tf_apply || warn "5.3 восстановление полного набора не удалось" + + backup_tfvars + 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 run_id + run_id=$(grep 'install_run_id' "$TFVARS" | grep -oP '\d+' | head -1) + run_id=$((run_id + 1)) + + write_tfvars "true" "true" "true" "$run_id" '["jq", "python3-pip", "htop", "unzip"]' + + info "запускаю переустановку через sless_job..." + if tf_apply; 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 + + backup_tfvars + 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 просыпается +# ══════════════════════════════════════════════════════════════════════════════ + +phase_8_resurrect() { + phase_header 8 "RESURRECT — apply после destroy" + + if tf_apply; 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 + + backup_tfvars + phase_result "RESURRECT" "PASS" +} + +# ══════════════════════════════════════════════════════════════════════════════ +# ФАЗА 9: STRESS_CYCLES — N destroy/apply циклов подряд +# ══════════════════════════════════════════════════════════════════════════════ + +phase_9_stress() { + phase_header 9 "STRESS_CYCLES — $STRESS_CYCLES циклов destroy/apply" + + local i + 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 упал" + # Попробовать восстановиться + tf_apply || true + continue + fi + + info "[$i] apply..." + if tf_apply; 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)" + # Попробовать восстановить + ensure_baseline + fi + + # Plan = no changes + if tf_plan_no_changes; 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" + + # Полная проверка пакетов + local all_ok=true + for pkg in jq htop unzip; do + if vm_check_package "$ip" "$pkg"; then + pass "10.4 пакет $pkg установлен" + else + fail "10.4 пакет $pkg НЕ установлен" + all_ok=false + 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 — examples/VM ║" +echo "║ $(date '+%Y-%m-%d %H:%M:%S') ║" +echo "║ Циклов stress: $STRESS_CYCLES ║" +echo "╚═══════════════════════════════════════════════════════════════╝" +echo -e "${RESET}" + +# Проверить что мы в правильной директории +if [[ ! -f "$TFVARS" ]]; then + echo -e "${RED}ОШИБКА: $TFVARS не найден. Запускайте из examples/VM/${RESET}" + exit 1 +fi + +if [[ ! -f "$VM_KEY" ]]; then + echo -e "${RED}ОШИБКА: $VM_KEY не найден${RESET}" + exit 1 +fi + +# Запуск фаз +phase_1_baseline +phase_2_idempotent +phase_3_partial_disable +phase_4_partial_enable +phase_5_reorder +phase_6_manual_purge + +if [[ "$SKIP_DESTROY" == "1" ]]; then + skip "фазы 7-9 пропущены (SKIP_DESTROY=1)" + PHASE_RESULTS+=("DESTROY:SKIP" "RESURRECT:SKIP" "STRESS_CYCLES:SKIP") +else + phase_7_destroy + phase_8_resurrect + phase_9_stress +fi + +phase_10_final + +# ── ИТОГОВАЯ СВОДКА ────────────────────────────────────────────────────────── + +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 "" +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 +restore_tfvars + +# 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 diff --git a/VM/README.md b/VM/README.md new file mode 100644 index 0000000..d5fe144 --- /dev/null +++ b/VM/README.md @@ -0,0 +1,93 @@ +# Пример: Виртуальная машина (vApp + VM) в Nubes vDC + +Создаёт: +- **vApp** — виртуальный каталог (контейнер для ВМ в VMware vDC) +- **ВМ** — Ubuntu 22.04, 2 CPU / 2 GB RAM / 20 GB disk + +--- + +## Что нужно сделать перед запуском + +### 1. Сгенерировать SSH-ключ + +Публичный ключ прописывается в ВМ при создании — это единственный способ зайти по SSH. +Приватный ключ нужен хранить у себя. + +```bash +ssh-keygen -t ed25519 -f ~/.ssh/sless-demo-vm -N "" -C "sless-demo-vm" +``` + +Публичный ключ (`~/.ssh/sless-demo-vm.pub`) — строка вида: +``` +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... sless-demo-vm +``` + +### 2. Заполнить terraform.tfvars + +Открыть файл `terraform.tfvars` и заменить значения: + +```hcl +# Ваш API-токен из панели Nubes +api_token = "ВСТАВИТЬ_ТОКЕН" + +# Публичный ключ из шага 1 +vm_public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA..." +``` + +> **Токен** — берётся в панели Nubes: профиль → API-токены. +> **Ключ** — содержимое файла `~/.ssh/sless-demo-vm.pub` (публичный, не приватный!). + +--- + +## Запуск + +```bash +cd examples/VM + +terraform init +terraform apply +``` + +После `apply` в выводе будет: + +``` +Outputs: + +vm_id = "..." +vm_state = { + "externalIp" = "1.2.3.4" + ... +} +vapp_id = "..." +``` + +--- + +## Подключение по SSH + +```bash +ssh -i ~/.ssh/sless-demo-vm ubuntu@ +``` + +Логин — `ubuntu` (задан в `vm.tf`). + +--- + +## Удаление + +```bash +terraform destroy +``` + +Порядок автоматический: сначала suspend → потом delete. Без suspend удаление упадёт с ошибкой — это поведение Nubes, параметр `suspend_on_destroy = true` в ресурсах решает это. + +--- + +## Что можно менять + +| Параметр | Файл | Примечание | +|----------|------|-----------| +| `vm_cpu`, `vm_ram`, `vm_disk` | `vm.tf` | Можно менять и переприменять | +| `resource_name`, `vapp_name` | `vapp.tf` | **Не изменяется после создания** | +| `image_vm`, `user_login`, `user_public_key` | `vm.tf` | **Не изменяется после создания** | +| `vdc_uid`, `nsxt_uid` | `vapp.tf` | **Не изменяется после создания** | diff --git a/VM/VM_TEST_README.md b/VM/VM_TEST_README.md new file mode 100644 index 0000000..ab9c7e4 --- /dev/null +++ b/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/VM/functions/install-docker/handler.py b/VM/functions/install-docker/handler.py new file mode 100644 index 0000000..c9660d3 --- /dev/null +++ b/VM/functions/install-docker/handler.py @@ -0,0 +1,158 @@ +# 2026-03-29 — handler.py: установка Docker CE на ВМ по SSH. +# sless_job runtime: python3.11, entrypoint: handler.install +# +# Метод установки: официальный Docker apt-репозиторий (best practices). +# НЕ используется curl | sh — небезопасно для продакшена. +# +# event_json: +# compose: true/false — ставить ли docker-compose-plugin (default: true) +# +# env_vars: +# VM_IP: внешний IP ВМ +# SSH_USER: логин (ubuntu) +# SSH_KEY: содержимое приватного SSH-ключа (PEM) + +import os, io, time +import paramiko + + +def _load_key(content): + for cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey): + try: + return cls.from_private_key(io.StringIO(content)) + except Exception: + pass + raise ValueError("Неподдерживаемый тип SSH-ключа") + + +def _ssh_connect(retries=5, delay=10): + key = _load_key(os.environ["SSH_KEY"]) + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + last_err = None + for attempt in range(retries): + try: + client.connect( + hostname=os.environ["VM_IP"], + username=os.environ["SSH_USER"], + pkey=key, + timeout=15, + ) + return client + except Exception as e: + last_err = e + if attempt < retries - 1: + time.sleep(delay) + raise RuntimeError(f"SSH не удалось после {retries} попыток: {last_err}") + + +def _run(client, cmd, timeout=120, check=True): + _, stdout, stderr = client.exec_command(cmd, timeout=timeout) + code = stdout.channel.recv_exit_status() + out = stdout.read().decode(errors="replace").strip() + err = stderr.read().decode(errors="replace").strip() + if check and code != 0: + raise RuntimeError(f"Ошибка (exit {code}):\n{cmd}\nstderr: {err}") + return code, out, err + + +def _wait_apt_lock(client, attempts=20, delay=10): + """Ждать завершения cloud-init и убить авто-обновления. Ubuntu 22.04+.""" + # Шаг 1: Ждём завершения cloud-init — он держит apt при первом старте VM + _run(client, "timeout 300 sudo cloud-init status --wait 2>/dev/null; true", check=False, timeout=310) + # Шаг 2: Mask (не просто disable) — systemd не сможет перезапустить + _run(client, "sudo systemctl mask unattended-upgrades apt-daily.service apt-daily-upgrade.service apt-daily.timer apt-daily-upgrade.timer 2>/dev/null; true", check=False) + _run(client, "sudo systemctl stop unattended-upgrades apt-daily.service apt-daily-upgrade.service 2>/dev/null; true", check=False) + # Шаг 3: Добить оставшиеся apt/dpkg процессы + _run(client, "sudo pkill -9 -x unattended-upgrades apt-get apt dpkg 2>/dev/null; true", check=False) + _run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False) + # Шаг 4: Убрать стейл-локи и починить dpkg + _run(client, "sudo rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock /var/lib/apt/lists/lock 2>/dev/null; true", check=False) + _run(client, "sudo dpkg --configure -a 2>/dev/null; true", check=False) + time.sleep(3) + + locks = ["/var/lib/dpkg/lock-frontend", "/var/lib/dpkg/lock", "/var/lib/apt/lists/lock"] + for i in range(attempts): + all_free = all( + _run(client, f"sudo flock -n {lock} true 2>/dev/null", check=False)[0] == 0 + for lock in locks + ) + if all_free: + return + _run(client, "sudo pkill -9 -x apt-get apt dpkg 2>/dev/null; true", check=False) + _run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False) + if i < attempts - 1: + time.sleep(delay) + raise RuntimeError("apt lock занят слишком долго — проверьте процессы на ВМ") + + +# Команды установки Docker CE через официальный apt-репозиторий. +# Источник: https://docs.docker.com/engine/install/ubuntu/ +_DOCKER_INSTALL_CMDS = [ + # Зависимости для добавления внешнего репозитория + "sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 install -y -qq ca-certificates curl gnupg", + # Директория для ключей + "sudo install -m 0755 -d /etc/apt/keyrings", + # GPG-ключ Docker + "curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor --batch --yes -o /etc/apt/keyrings/docker.gpg", + "sudo chmod a+r /etc/apt/keyrings/docker.gpg", + # Docker apt-репозиторий + ( + 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] ' + 'https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" ' + "| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null" + ), + # Обновить индекс с новым репо + "sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 update -qq", + # Установить Docker CE + "sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 install -y -qq docker-ce docker-ce-cli containerd.io", +] + + +def install(event): + """Установить Docker CE. Если уже установлен — вернуть версию.""" + install_compose = event.get("compose", True) + + client = _ssh_connect() + try: + # Проверить: уже установлен? + code, ver_out, _ = _run(client, "docker --version 2>&1", check=False) + if code == 0 and "Docker version" in ver_out: + _, compose_out, _ = _run(client, "docker compose version 2>&1", check=False) + return { + "status": "already_installed", + "docker_version": ver_out, + "compose_version": compose_out if "Docker Compose" in compose_out else None, + } + + _wait_apt_lock(client) + + for cmd in _DOCKER_INSTALL_CMDS: + _run(client, cmd, timeout=180) + + if install_compose: + _run( + client, + "sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker-compose-plugin", + timeout=120, + ) + + # Добавить пользователя в группу docker (чтобы запускать без sudo) + ssh_user = os.environ["SSH_USER"] + _run(client, f"sudo usermod -aG docker {ssh_user}", check=False) + + # Проверка: запустить hello-world + # Используем sudo т.к. usermod не применится до переподключения + _run(client, "sudo docker run --rm hello-world", timeout=120) + + _, ver_out, _ = _run(client, "docker --version", check=False) + _, compose_out, _ = _run(client, "docker compose version 2>&1", check=False) + + return { + "status": "ok", + "docker_version": ver_out, + "compose_version": compose_out if "Docker Compose" in compose_out else None, + "note": f"user '{ssh_user}' added to docker group (reconnect to use without sudo)", + } + finally: + client.close() diff --git a/VM/functions/install-docker/requirements.txt b/VM/functions/install-docker/requirements.txt new file mode 100644 index 0000000..d2baed5 --- /dev/null +++ b/VM/functions/install-docker/requirements.txt @@ -0,0 +1,2 @@ +paramiko +# v6 diff --git a/VM/functions/install-nginx/handler.py b/VM/functions/install-nginx/handler.py new file mode 100644 index 0000000..70214eb --- /dev/null +++ b/VM/functions/install-nginx/handler.py @@ -0,0 +1,129 @@ +# 2026-03-29 — handler.py: установка nginx на ВМ по SSH. +# sless_job runtime: python3.11, entrypoint: handler.install +# +# event_json: {} (параметров нет — nginx ставится с дефолтной конфигурацией) +# +# env_vars: +# VM_IP: внешний IP ВМ +# SSH_USER: логин (ubuntu) +# SSH_KEY: содержимое приватного SSH-ключа (PEM) + +import os, io, time +import paramiko + + +def _load_key(content): + for cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey): + try: + return cls.from_private_key(io.StringIO(content)) + except Exception: + pass + raise ValueError("Неподдерживаемый тип SSH-ключа") + + +def _ssh_connect(retries=5, delay=10): + key = _load_key(os.environ["SSH_KEY"]) + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + last_err = None + for attempt in range(retries): + try: + client.connect( + hostname=os.environ["VM_IP"], + username=os.environ["SSH_USER"], + pkey=key, + timeout=15, + ) + return client + except Exception as e: + last_err = e + if attempt < retries - 1: + time.sleep(delay) + raise RuntimeError(f"SSH не удалось после {retries} попыток: {last_err}") + + +def _run(client, cmd, timeout=120, check=True): + _, stdout, stderr = client.exec_command(cmd, timeout=timeout) + code = stdout.channel.recv_exit_status() + out = stdout.read().decode(errors="replace").strip() + err = stderr.read().decode(errors="replace").strip() + if check and code != 0: + raise RuntimeError(f"Ошибка (exit {code}):\n{cmd}\nstderr: {err}") + return code, out, err + + +def _wait_apt_lock(client, attempts=20, delay=10): + """Ждать завершения cloud-init и убить авто-обновления. Ubuntu 22.04+.""" + # Шаг 1: Ждём завершения cloud-init — он держит apt при первом старте VM + _run(client, "timeout 300 sudo cloud-init status --wait 2>/dev/null; true", check=False, timeout=310) + # Шаг 2: Mask (не просто disable) — systemd не сможет перезапустить + _run(client, "sudo systemctl mask unattended-upgrades apt-daily.service apt-daily-upgrade.service apt-daily.timer apt-daily-upgrade.timer 2>/dev/null; true", check=False) + _run(client, "sudo systemctl stop unattended-upgrades apt-daily.service apt-daily-upgrade.service 2>/dev/null; true", check=False) + # Шаг 3: Добить оставшиеся apt/dpkg процессы + _run(client, "sudo pkill -9 -x unattended-upgrades apt-get apt dpkg 2>/dev/null; true", check=False) + _run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False) + # Шаг 4: Убрать стейл-локи и починить dpkg + _run(client, "sudo rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock /var/lib/apt/lists/lock 2>/dev/null; true", check=False) + _run(client, "sudo dpkg --configure -a 2>/dev/null; true", check=False) + time.sleep(3) + + locks = ["/var/lib/dpkg/lock-frontend", "/var/lib/dpkg/lock", "/var/lib/apt/lists/lock"] + for i in range(attempts): + all_free = all( + _run(client, f"sudo flock -n {lock} true 2>/dev/null", check=False)[0] == 0 + for lock in locks + ) + if all_free: + return + _run(client, "sudo pkill -9 -x apt-get apt dpkg 2>/dev/null; true", check=False) + _run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False) + if i < attempts - 1: + time.sleep(delay) + raise RuntimeError("apt lock занят слишком долго — проверьте процессы на ВМ") + + +def install(event): + """Установить nginx. Если уже установлен — проверить что запущен.""" + client = _ssh_connect() + try: + # Проверить: уже установлен? + code, ver_out, _ = _run(client, "nginx -v 2>&1", check=False) + already_installed = "nginx version" in ver_out + + if already_installed: + # Убедиться что сервис запущен + _run(client, "sudo systemctl start nginx", check=False) + version = ver_out.replace("nginx version: nginx/", "").strip() + _, http_code, _ = _run( + client, "curl -s -o /dev/null -w '%{http_code}' http://localhost", check=False + ) + return { + "status": "already_installed", + "version": version, + "http_check": http_code, + } + + _wait_apt_lock(client) + _run(client, "sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 update -qq", timeout=420) + _run( + client, + "sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 install -y -qq nginx", + timeout=300, + ) + _run(client, "sudo systemctl enable nginx") + _run(client, "sudo systemctl start nginx") + + # Проверить HTTP-ответ на localhost + _, http_code, _ = _run( + client, "curl -s -o /dev/null -w '%{http_code}' http://localhost", check=False + ) + _, ver_out, _ = _run(client, "nginx -v 2>&1", check=False) + version = ver_out.replace("nginx version: nginx/", "").strip() + + return { + "status": "ok", + "version": version, + "http_check": http_code, + } + finally: + client.close() diff --git a/VM/functions/install-nginx/requirements.txt b/VM/functions/install-nginx/requirements.txt new file mode 100644 index 0000000..d2baed5 --- /dev/null +++ b/VM/functions/install-nginx/requirements.txt @@ -0,0 +1,2 @@ +paramiko +# v6 diff --git a/VM/functions/install-packages/handler.py b/VM/functions/install-packages/handler.py new file mode 100644 index 0000000..cf47ff7 --- /dev/null +++ b/VM/functions/install-packages/handler.py @@ -0,0 +1,133 @@ +# 2026-03-29 — handler.py: установка apt-пакетов на ВМ по SSH. +# sless_job runtime: python3.11, entrypoint: handler.install +# +# event_json: +# packages: ["git", "curl", ...] — список пакетов (обязательно) +# update: true/false — apt-get update перед install (default: true) +# +# env_vars: +# VM_IP: внешний IP ВМ +# SSH_USER: логин (ubuntu) +# SSH_KEY: содержимое приватного SSH-ключа (PEM) + +import os, io, time +import paramiko + + +def _load_key(content): + """Загрузить SSH-ключ (Ed25519 / RSA / ECDSA).""" + for cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey): + try: + return cls.from_private_key(io.StringIO(content)) + except Exception: + pass + raise ValueError("Неподдерживаемый тип SSH-ключа") + + +def _ssh_connect(retries=5, delay=10): + """Подключение к ВМ с retry — ВМ может ещё загружаться.""" + key = _load_key(os.environ["SSH_KEY"]) + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + last_err = None + for attempt in range(retries): + try: + client.connect( + hostname=os.environ["VM_IP"], + username=os.environ["SSH_USER"], + pkey=key, + timeout=15, + ) + return client + except Exception as e: + last_err = e + if attempt < retries - 1: + time.sleep(delay) + raise RuntimeError(f"SSH не удалось после {retries} попыток: {last_err}") + + +def _run(client, cmd, timeout=120, check=True): + """Выполнить команду, вернуть (exit_code, stdout, stderr).""" + _, stdout, stderr = client.exec_command(cmd, timeout=timeout) + code = stdout.channel.recv_exit_status() + out = stdout.read().decode(errors="replace").strip() + err = stderr.read().decode(errors="replace").strip() + if check and code != 0: + raise RuntimeError(f"Ошибка (exit {code}):\n{cmd}\nstderr: {err}") + return code, out, err + + +def _wait_apt_lock(client, attempts=20, delay=10): + """Ждать завершения cloud-init и убить авто-обновления. Ubuntu 22.04+.""" + # Шаг 1: Ждём завершения cloud-init — он держит apt при первом старте VM + _run(client, "timeout 300 sudo cloud-init status --wait 2>/dev/null; true", check=False, timeout=310) + # Шаг 2: Mask (не просто disable) — systemd не сможет перезапустить + _run(client, "sudo systemctl mask unattended-upgrades apt-daily.service apt-daily-upgrade.service apt-daily.timer apt-daily-upgrade.timer 2>/dev/null; true", check=False) + _run(client, "sudo systemctl stop unattended-upgrades apt-daily.service apt-daily-upgrade.service 2>/dev/null; true", check=False) + # Шаг 3: Добить оставшиеся apt/dpkg процессы + _run(client, "sudo pkill -9 -x unattended-upgrades apt-get apt dpkg 2>/dev/null; true", check=False) + _run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False) + # Шаг 4: Убрать стейл-локи и починить dpkg + _run(client, "sudo rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock /var/lib/apt/lists/lock 2>/dev/null; true", check=False) + _run(client, "sudo dpkg --configure -a 2>/dev/null; true", check=False) + time.sleep(3) + + locks = [ + "/var/lib/dpkg/lock-frontend", + "/var/lib/dpkg/lock", + "/var/lib/apt/lists/lock", + ] + for i in range(attempts): + all_free = all( + _run(client, f"sudo flock -n {lock} true 2>/dev/null", check=False)[0] == 0 + for lock in locks + ) + if all_free: + return + # Повторить убийство процессов удерживающих lock + _run(client, "sudo pkill -9 -x apt-get apt dpkg 2>/dev/null; true", check=False) + _run(client, "sudo kill -9 $(sudo lsof -t /var/lib/dpkg/lock-frontend 2>/dev/null) 2>/dev/null; true", check=False) + if i < attempts - 1: + time.sleep(delay) + raise RuntimeError("apt lock занят слишком долго — проверьте процессы на ВМ") + + +def install(event): + """Установить apt-пакеты. Идемпотентно — повторный запуск безопасен.""" + packages = event.get("packages", []) + if not packages: + return {"status": "skipped", "reason": "packages list is empty"} + + do_update = event.get("update", True) + + client = _ssh_connect() + try: + _wait_apt_lock(client) + + if do_update: + _run(client, "sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 update -qq", timeout=420) + + pkg_str = " ".join(packages) + _run( + client, + f"sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 install -y -qq {pkg_str}", + timeout=300, + ) + + # Проверить что установилось + installed, missing = [], [] + for pkg in packages: + code, _, _ = _run( + client, + f"dpkg -l {pkg} 2>/dev/null | grep -q '^ii'", + check=False, + ) + (installed if code == 0 else missing).append(pkg) + + return { + "status": "ok" if not missing else "partial", + "installed": installed, + "missing": missing, + } + finally: + client.close() diff --git a/VM/functions/install-packages/requirements.txt b/VM/functions/install-packages/requirements.txt new file mode 100644 index 0000000..d2baed5 --- /dev/null +++ b/VM/functions/install-packages/requirements.txt @@ -0,0 +1,2 @@ +paramiko +# v6 diff --git a/VM/main.tf b/VM/main.tf new file mode 100644 index 0000000..866902e --- /dev/null +++ b/VM/main.tf @@ -0,0 +1,42 @@ +// 2026-03-25 — main.tf для примера с vApp + ВМ (Виртуальный датацентр Nubes). +// Провайдер nubes. Sless-провайдер не нужен — пример чисто инфраструктурный. + +terraform { + required_providers { + nubes = { + source = "terra.k8c.ru/nubes/nubes" + version = "5.0.51" + } + sless = { + source = "terra.k8c.ru/naeel/sless" + version = "~> 0.1" + } + } +} + +# ------------------------------------------------------------------ +# Переменные +# ------------------------------------------------------------------ + +variable "vm_public_key" { + type = string + sensitive = true + description = "Публичный SSH-ключ для ВМ. Приватный ключ: ~/terra/sless/examples/VM/vm_key" +} + +variable "api_token" { + type = string + sensitive = true + description = "Nubes API token" +} + +# ------------------------------------------------------------------ +# Провайдер +# ------------------------------------------------------------------ + +# API Dashboard (для Terraform-провайдеров): https://deck-api-test.ngcloud.ru/api/v1/index.cfm +# UI облака (только браузер): https://deck-test.ngcloud.ru/ +provider "nubes" { + api_token = var.api_token + api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" +} diff --git a/VM/outputs.tf b/VM/outputs.tf new file mode 100644 index 0000000..cda642a --- /dev/null +++ b/VM/outputs.tf @@ -0,0 +1,18 @@ +# 2026-03-29 — outputs.tf: результаты установки ПО на ВМ. +# phase: Pending / Building / Running / Succeeded / Failed +# message: JSON с деталями (что установлено) или traceback при ошибке + +output "install_packages_result" { + description = "Результат установки базовых пакетов" + value = var.install_packages ? sless_job.install_packages[0].message : "skipped" +} + +output "install_nginx_result" { + description = "Результат установки nginx" + value = var.install_nginx ? sless_job.install_nginx[0].message : "skipped" +} + +output "install_docker_result" { + description = "Результат установки Docker" + value = var.install_docker ? sless_job.install_docker[0].message : "skipped" +} diff --git a/VM/sless.tf b/VM/sless.tf new file mode 100644 index 0000000..e120857 --- /dev/null +++ b/VM/sless.tf @@ -0,0 +1,108 @@ +# 2026-03-29 — sless.tf: провайдер sless и sless_job ресурсы для установки ПО на ВМ. +# +# Схема работы: +# 1. terraform apply создаёт FunctionJob CR в k8s +# 2. Провайдер загружает код из source_dir в S3 +# 3. Оператор собирает Docker-образ (kaniko) и запускает Job +# 4. Job подключается к ВМ по SSH и устанавливает ПО +# 5. terraform apply завершается: outputs содержат статус каждого шага +# +# Для повторного запуска: увеличь install_run_id в terraform.tfvars → terraform apply + +# --------------------------------------------------------------------------- +# Провайдер +# --------------------------------------------------------------------------- + +provider "sless" { + endpoint = "https://sless.kube5s.ru" + token = var.api_token # тот же JWT что и в provider "nubes" + nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" +} + +# --------------------------------------------------------------------------- +# Общие locals: SSH-параметры для подключения к ВМ +# --------------------------------------------------------------------------- + +locals { + # TODO: заменить externalConnect → internalConnect когда DevOps настроят + # сеть между k8s кластером и Nubes vDC (сейчас только внешний IP доступен). + vm_ip = nubes_vc_vm_v3.vm.state_out_flat["externalConnect"] + + ssh_env = { + VM_IP = local.vm_ip + SSH_USER = "ubuntu" + # TODO(vault): заменить на чтение из Vault когда сервис заработает; пока тестовый стенд — прямой файл. + SSH_KEY = file("${path.module}/vm_key") + } +} + +# --------------------------------------------------------------------------- +# Job 1: базовые пакеты (jq, pip3 и др.) +# --------------------------------------------------------------------------- + +resource "sless_job" "install_packages" { + count = var.install_packages ? 1 : 0 + + name = "vm-install-packages" + runtime = "python3.11" + entrypoint = "handler.install" + source_dir = "${path.module}/functions/install-packages" + memory_mb = 128 + + env_vars = local.ssh_env + event_json = jsonencode({ + packages = var.base_packages + update = true + }) + + run_id = var.install_run_id + wait_timeout_sec = 600 + + depends_on = [nubes_vc_vm_v3.vm] +} + +# --------------------------------------------------------------------------- +# Job 2: nginx +# --------------------------------------------------------------------------- + +resource "sless_job" "install_nginx" { + count = var.install_nginx ? 1 : 0 + + name = "vm-install-nginx" + runtime = "python3.11" + entrypoint = "handler.install" + source_dir = "${path.module}/functions/install-nginx" + memory_mb = 128 + + env_vars = local.ssh_env + event_json = jsonencode({}) + + run_id = var.install_run_id + wait_timeout_sec = 600 + + depends_on = [nubes_vc_vm_v3.vm, sless_job.install_packages] +} + +# --------------------------------------------------------------------------- +# Job 3: Docker CE +# --------------------------------------------------------------------------- + +resource "sless_job" "install_docker" { + count = var.install_docker ? 1 : 0 + + name = "vm-install-docker" + runtime = "python3.11" + entrypoint = "handler.install" + source_dir = "${path.module}/functions/install-docker" + memory_mb = 128 + + env_vars = local.ssh_env + event_json = jsonencode({ + compose = true + }) + + run_id = var.install_run_id + wait_timeout_sec = 900 + + depends_on = [nubes_vc_vm_v3.vm, sless_job.install_packages, sless_job.install_nginx] +} diff --git a/VM/vapp.tf b/VM/vapp.tf new file mode 100644 index 0000000..e6620e9 --- /dev/null +++ b/VM/vapp.tf @@ -0,0 +1,28 @@ +// 2026-03-25 — vapp.tf: виртуальный каталог ВМ (vApp) в Nubes vDC. +// nubes_vapp — контейнер для ВМ внутри Виртуального датацентра. +// Обязательные поля: vdc_uid, nsxt_uid, vapp_name, resource_name. + +resource "nubes_vapp" "vapp" { + resource_name = "vm-sless-vapp" + vapp_name = "vapp-sless" # Уникальное в рамках организации. Не изменяется после создания. + vdc_uid = "e3c9e4f1-24da-4992-a003-f8a2a803a5f0" # UUID Услуги «Виртуальный датацентр (vDC)». Не изменяется после создания. + nsxt_uid = "0fe88e2a-31b6-4385-ad52-e27c6c0d38a6" # UUID Услуги «Сетевой шлюз периметра (Edge)». Не изменяется после создания. + + adopt_existing_on_create = true + operation_timeout = "15m" + + # ВАЖНО: delete без предварительного suspend завершается ошибкой + # "Невозможно выполнить операцию удаления услуги. Услуга не остановлена" + # suspend_on_destroy гарантирует правильный порядок: suspend → delete. + suspend_on_destroy = true +} + +output "vapp_id" { + value = nubes_vapp.vapp.id + description = "ID созданного vApp (используется как vapp_uid при создании ВМ)" +} + +output "vapp_state" { + value = nubes_vapp.vapp.state_out_flat + description = "Плоский state vApp — адреса, статусы сети и т.д." +} diff --git a/VM/variables.tf b/VM/variables.tf new file mode 100644 index 0000000..de95a50 --- /dev/null +++ b/VM/variables.tf @@ -0,0 +1,37 @@ +# 2026-03-29 — variables.tf: переменные для sless и установки ПО на ВМ. +# Переменные nubes (api_token, vm_public_key) остаются в main.tf. +# sless использует тот же api_token — отдельной переменной не нужно. + +# ---- Флаги: что устанавливать на ВМ -------------------------------------- + +variable "install_packages" { + type = bool + default = true + description = "Установить базовые apt-пакеты (jq и др.)" +} + +variable "install_nginx" { + type = bool + default = false + description = "Установить nginx" +} + +variable "install_docker" { + type = bool + default = false + description = "Установить Docker CE + docker-compose-plugin" +} + +# ---- Параметры ------------------------------------------------------------ + +variable "base_packages" { + type = list(string) + default = ["jq", "python3-pip", "htop", "unzip"] + description = "Список apt-пакетов для install-packages" +} + +variable "install_run_id" { + type = number + default = 1 + description = "Увеличь на 1 чтобы запустить все install-джобы заново" +} diff --git a/VM/vm.tf b/VM/vm.tf new file mode 100644 index 0000000..a9110fa --- /dev/null +++ b/VM/vm.tf @@ -0,0 +1,37 @@ +// 2026-03-25 — vm.tf: виртуальная машина (nubes_vc_vm_v3) внутри vApp. +// Зависит от nubes_vapp.vapp — создаётся после vApp. +// image_vm, vapp_uid, user_public_key не изменяются после создания. + +resource "nubes_vc_vm_v3" "vm" { + resource_name = "vm-sless-1" + vm_name = "web02" # Имя ВМ в Nubes vCD. Не изменяется после создания. + # Определяет имя NSX-T IP Set: {vapp_name}-{vm_name} + + vapp_uid = nubes_vapp.vapp.id # ссылка на vApp. Не изменяется после создания. + image_vm = "Ubuntu_22-20G" # Не изменяется после создания. + # image_vm = "Ubuntu 22.04 LTS" # Не изменяется после создания. + ip_space_name = "internet-ipv4-v1" + + user_login = "ubuntu" + user_public_key = var.vm_public_key # задаётся в terraform.tfvars + + vm_cpu = 2 + vm_ram = 2 # GB + vm_disk = 20 # GB + + adopt_existing_on_create = true + operation_timeout = "15m" + + # delete без предварительного suspend завершается ошибкой (аналогично vApp). + suspend_on_destroy = true +} + +output "vm_id" { + value = nubes_vc_vm_v3.vm.id + description = "ID созданной ВМ" +} + +output "vm_state" { + value = nubes_vc_vm_v3.vm.state_out_flat + description = "Плоский state ВМ — IP-адреса, статус и т.д." +} diff --git a/VM/vm_key b/VM/vm_key new file mode 100644 index 0000000..b754d1e --- /dev/null +++ b/VM/vm_key @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACCkAfisnr59B/EEnX5umaQlPY7bJAcVEvQVdPotfzNOAAAAAJBioDTmYqA0 +5gAAAAtzc2gtZWQyNTUxOQAAACCkAfisnr59B/EEnX5umaQlPY7bJAcVEvQVdPotfzNOAA +AAAEC4nFg/UaIitvoJKhJsrroOHWgmmkfHYQRyvEzqGe+AwaQB+Kyevn0H8QSdfm6ZpCU9 +jtskBxUS9BV0+i1/M04AAAAADXNsZXNzLWRlbW8tdm0= +-----END OPENSSH PRIVATE KEY----- diff --git a/VM/vm_key.pub b/VM/vm_key.pub new file mode 100644 index 0000000..8654b4e --- /dev/null +++ b/VM/vm_key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKQB+Kyevn0H8QSdfm6ZpCU9jtskBxUS9BV0+i1/M04A sless-demo-vm diff --git a/VM/vm_stress_test.sh b/VM/vm_stress_test.sh new file mode 100755 index 0000000..b217d61 --- /dev/null +++ b/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