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}
-
- | # | title | created_at |
- {rows_html}
-
-
-"""
-
-
-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