From 014c14af5eae30530c988f219355d78b401342e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sun, 22 Mar 2026 17:08:18 +0400 Subject: [PATCH] 0 --- POSTGRES/bug_hunter.sh | 650 +++++++++++++++++ POSTGRES/chaos_marathon.sh | 668 ++++++++++++++++++ POSTGRES/chaos_marathon.tf | 301 ++++++++ .../code/chaos-badparams/chaos_badparams.py | 40 ++ .../code/chaos-bigpayload/chaos_bigpayload.py | 27 + POSTGRES/code/chaos-echo/chaos_echo.py | 19 + .../code/chaos-slowquery/chaos_slowquery.py | 19 + .../code/chaos-slowquery/requirements.txt | 1 + POSTGRES/code/go-counter-atomic/handler.go | 55 ++ POSTGRES/code/go-pg-race/handler.go | 94 +++ POSTGRES/code/js-idempotent/js_idempotent.js | 58 ++ POSTGRES/code/js-idempotent/package.json | 7 + POSTGRES/code/js-pg-batch/js_pg_batch.js | 43 ++ POSTGRES/code/js-pg-batch/package.json | 7 + .../code/pg-bulk-insert/pg_bulk_insert.py | 38 + POSTGRES/code/pg-bulk-insert/requirements.txt | 1 + POSTGRES/code/pg-counter/pg_counter.py | 23 + POSTGRES/code/pg-counter/requirements.txt | 1 + POSTGRES/code/pg-dedup/pg_dedup.py | 38 + POSTGRES/code/pg-dedup/requirements.txt | 1 + POSTGRES/code/pg-delete-old/pg_delete_old.py | 33 + POSTGRES/code/pg-delete-old/requirements.txt | 1 + POSTGRES/code/pg-search/pg_search.py | 36 + POSTGRES/code/pg-search/requirements.txt | 1 + POSTGRES/code/pg-upsert/pg_upsert.py | 36 + POSTGRES/code/pg-upsert/requirements.txt | 1 + .../code/py-retry-writer/py_retry_writer.py | 54 ++ .../code/py-retry-writer/requirements.txt | 1 + POSTGRES/deploy_and_run_chaos.sh | 39 + POSTGRES/main.tf | 2 + 30 files changed, 2295 insertions(+) create mode 100644 POSTGRES/bug_hunter.sh create mode 100644 POSTGRES/chaos_marathon.sh create mode 100644 POSTGRES/chaos_marathon.tf create mode 100644 POSTGRES/code/chaos-badparams/chaos_badparams.py create mode 100644 POSTGRES/code/chaos-bigpayload/chaos_bigpayload.py create mode 100644 POSTGRES/code/chaos-echo/chaos_echo.py create mode 100644 POSTGRES/code/chaos-slowquery/chaos_slowquery.py create mode 100644 POSTGRES/code/chaos-slowquery/requirements.txt create mode 100644 POSTGRES/code/go-counter-atomic/handler.go create mode 100644 POSTGRES/code/go-pg-race/handler.go create mode 100644 POSTGRES/code/js-idempotent/js_idempotent.js create mode 100644 POSTGRES/code/js-idempotent/package.json create mode 100644 POSTGRES/code/js-pg-batch/js_pg_batch.js create mode 100644 POSTGRES/code/js-pg-batch/package.json create mode 100644 POSTGRES/code/pg-bulk-insert/pg_bulk_insert.py create mode 100644 POSTGRES/code/pg-bulk-insert/requirements.txt create mode 100644 POSTGRES/code/pg-counter/pg_counter.py create mode 100644 POSTGRES/code/pg-counter/requirements.txt create mode 100644 POSTGRES/code/pg-dedup/pg_dedup.py create mode 100644 POSTGRES/code/pg-dedup/requirements.txt create mode 100644 POSTGRES/code/pg-delete-old/pg_delete_old.py create mode 100644 POSTGRES/code/pg-delete-old/requirements.txt create mode 100644 POSTGRES/code/pg-search/pg_search.py create mode 100644 POSTGRES/code/pg-search/requirements.txt create mode 100644 POSTGRES/code/pg-upsert/pg_upsert.py create mode 100644 POSTGRES/code/pg-upsert/requirements.txt create mode 100644 POSTGRES/code/py-retry-writer/py_retry_writer.py create mode 100644 POSTGRES/code/py-retry-writer/requirements.txt create mode 100644 POSTGRES/deploy_and_run_chaos.sh diff --git a/POSTGRES/bug_hunter.sh b/POSTGRES/bug_hunter.sh new file mode 100644 index 0000000..d6b041f --- /dev/null +++ b/POSTGRES/bug_hunter.sh @@ -0,0 +1,650 @@ +#!/usr/bin/env bash +# 2026-03-21 — bug_hunter.sh: охота за багами во всех POSTGRES-функциях. +# Цель: найти bugs типа "ложный 200", "должен 500 но 200", неверные данные. +# Логика принципиально отличается от chaos_marathon — здесь акцент на семантике и data integrity. +# Запускать ТОЛЬКО на VM через SSH. + +set -euo pipefail + +BASE_URL="${BASE_URL:-https://sless.kube5s.ru}" +NAMESPACE="${NAMESPACE:-sless-ffd1f598c169b0ae}" +TOKEN_FILE="${TOKEN_FILE:-$HOME/terra/sless/test.token}" +TOKEN=$(cat "$TOKEN_FILE") + +RED="\033[0;31m"; GREEN="\033[0;32m"; YELLOW="\033[1;33m"; CYAN="\033[0;36m"; NC="\033[0m" + +PASS=0; FAIL=0; TOTAL=0 +BUGS_FOUND=() + +ts() { date '+%H:%M:%S'; } + +# ── Хелперы ────────────────────────────────────────────────────────────────── +raw() { + local svc="$1" payload="$2" + curl -sf -X POST -H "Content-Type: application/json" \ + -d "$payload" "${BASE_URL}/fn/${NAMESPACE}/${svc}" 2>/dev/null || echo "__CURL_FAIL__" +} + +http_code() { + local svc="$1" payload="$2" + curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" \ + -d "$payload" "${BASE_URL}/fn/${NAMESPACE}/${svc}" 2>/dev/null || echo "000" +} + +# Проверяем что HTTP-код РАВЕН ожидаемому +check_http() { + local label="$1" got="$2" want="$3" + TOTAL=$((TOTAL+1)) + if [[ "$got" == "$want" ]]; then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] $label" + PASS=$((PASS+1)) + else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] $label (got HTTP $got, want HTTP $want)" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("$label → HTTP $got ≠ $want") + fi +} + +# Проверяем что JSON содержит строку +check_has() { + local label="$1" body="$2" substr="$3" + TOTAL=$((TOTAL+1)) + if echo "$body" | grep -q "$substr" 2>/dev/null; then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] $label" + PASS=$((PASS+1)) + else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] $label (key/substr '$substr' not found in: ${body:0:120})" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("$label → '$substr' not in response") + fi +} + +# Проверяем что JSON НЕ содержит строку +check_not() { + local label="$1" body="$2" substr="$3" + TOTAL=$((TOTAL+1)) + if echo "$body" | grep -q "$substr" 2>/dev/null; then + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] $label (found '$substr' but should NOT be there: ${body:0:120})" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("$label → '$substr' should NOT be in response") + else + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] $label" + PASS=$((PASS+1)) + fi +} + +# Проверяем числовое равенство: jq вытаскивает значение и сравниваем +check_val() { + local label="$1" body="$2" jq_expr="$3" want="$4" + local got + got=$(echo "$body" | python3 -c " +import json,sys +try: + d=json.load(sys.stdin) + expr='$jq_expr'.lstrip('.') + parts=expr.split('.') + val=d + for p in parts: + val=val[p] if isinstance(val,dict) else val[int(p)] + print(val) +except Exception as e: + print('__ERR__:'+str(e)) +" 2>/dev/null || echo "__ERR__") + TOTAL=$((TOTAL+1)) + if [[ "$got" == "$want" ]]; then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] $label" + PASS=$((PASS+1)) + else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] $label (got '$got', want '$want')" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("$label → got '$got' want '$want'") + fi +} + +# Проверяем что числовое значение >= порога +check_gte() { + local label="$1" got="$2" min_val="$3" + TOTAL=$((TOTAL+1)) + if [[ "$got" =~ ^[0-9]+$ ]] && (( got >= min_val )); then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] $label (got $got >= $min_val)" + PASS=$((PASS+1)) + else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] $label (got '$got', want >= $min_val)" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("$label → $got < $min_val") + fi +} + +section() { + echo "" + echo -e "${CYAN}════════════════════════════════════════════════════════════${NC}" + echo -e "${CYAN} $1${NC}" + echo -e "${CYAN}════════════════════════════════════════════════════════════${NC}" +} + +echo -e "${YELLOW}" +echo "╔══════════════════════════════════════════════════════════╗" +echo "║ BUG HUNTER — $(date '+%Y-%m-%d %H:%M:%S') ║" +echo "║ Ищем: ложные 200, неверные данные, скрытые баги ║" +echo "╚══════════════════════════════════════════════════════════╝" +echo -e "${NC}" + +# ═══════════════════════════════════════════════════════════════════════════════ +section "БЛОК 1 — Тест на Missing Input Validation (должно быть 200, НО БУДЕТ 500 если баг есть)" +# ═══════════════════════════════════════════════════════════════════════════════ +echo " Каждая функция должна СТОЙКО обрабатывать строку вместо числа." +echo " Если возвращает 500 — это BUG: не хватает try/except." + +c=$(http_code "chaos-slowquery" '{"sleep_sec": "не_число"}') +check_http "BUG1: chaos-slowquery sleep_sec=string → должен 200" "$c" "200" + +c=$(http_code "chaos-bigpayload" '{"size_kb": "не_число"}') +check_http "BUG2: chaos-bigpayload size_kb=string → должен 200" "$c" "200" + +c=$(http_code "py-retry-writer" '{"n": "не_число"}') +check_http "BUG3: py-retry-writer n=string → должен 200" "$c" "200" + +c=$(http_code "pg-delete-old" '{"older_than_min": "не_число"}') +check_http "BUG4: pg-delete-old older_than_min=string → должен 200" "$c" "200" + +c=$(http_code "chaos-slowquery" '{"sleep_sec": null}') +check_http "BUG5: chaos-slowquery sleep_sec=null → должен 200" "$c" "200" + +c=$(http_code "chaos-bigpayload" '{"size_kb": null}') +check_http "BUG6: chaos-bigpayload size_kb=null → должен 200" "$c" "200" + +c=$(http_code "chaos-bigpayload" '{"size_kb": -999}') +check_http "BUG7: chaos-bigpayload size_kb=-999 (negative) → должен 200" "$c" "200" + +c=$(http_code "chaos-slowquery" '{"sleep_sec": -5}') +check_http "BUG8: chaos-slowquery sleep_sec=-5 → должен 200" "$c" "200" + +c=$(http_code "chaos-slowquery" '{"sleep_sec": 99999}') +check_http "BUG9: chaos-slowquery sleep_sec=99999 (huge) → должен 200" "$c" "200" + +c=$(http_code "py-retry-writer" '{"n": -1}') +check_http "BUG10: py-retry-writer n=-1 (negative) → должен 200" "$c" "200" + +c=$(http_code "py-retry-writer" '{"n": 99999}') +check_http "BUG11: py-retry-writer n=99999 (huge, capped) → должен 200" "$c" "200" + +# ═══════════════════════════════════════════════════════════════════════════════ +section "БЛОК 2 — Data Integrity: реальное значение vs ожидаемое" +# ═══════════════════════════════════════════════════════════════════════════════ + +# js-pg-batch: n=0 должен вернуть inserted=0, а не 20 (баг: 0||20=20) +r=$(raw "js-pg-batch" '{"n": 0, "prefix": "bughunt-zero"}') +check_val "BUG12: js-pg-batch n=0 → inserted должен быть 0" "$r" ".inserted" "0" + +# js-pg-batch: n=1 → ровно 1 строка +r=$(raw "js-pg-batch" '{"n": 1, "prefix": "bughunt-one"}') +check_val "SAFE: js-pg-batch n=1 → inserted=1" "$r" ".inserted" "1" + +# js-pg-batch: n=200 (cap) → не больше 200 +r=$(raw "js-pg-batch" '{"n": 9999, "prefix": "bughunt-cap"}') +i=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin)['inserted'])" 2>/dev/null || echo "-1") +check_gte "SAFE: js-pg-batch n=9999 → capped (inserted > 0)" "$i" 1 +# inserted должно быть <= 200 +TOTAL=$((TOTAL+1)) +if (( i <= 200 )); then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: js-pg-batch n=9999 → capped <= 200 (got $i)" + PASS=$((PASS+1)) +else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] BUG: js-pg-batch n=9999 → вставил $i строк (ожидали <= 200)" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("js-pg-batch n=9999 → inserted=$i > 200") +fi + +# pg-bulk-insert: точное соответствие n → inserted +for n_val in 1 10 100 499 500; do + r=$(raw "pg-bulk-insert" "{\"n\": $n_val, \"prefix\": \"bughunt-n$n_val\"}") + check_val "SAFE: pg-bulk-insert n=$n_val → inserted=$n_val" "$r" ".inserted" "$n_val" +done + +# py-retry-writer с simulate_error: должен вернуть ok:true (retry работает) +r=$(raw "py-retry-writer" '{"n": 5, "simulate_error": true, "prefix": "bughunt-retry"}') +check_has "SAFE: py-retry-writer simulate_error=true → ok:true" "$r" '"ok": true' +attempts=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin).get('attempts',0))" 2>/dev/null || echo "0") +check_gte "SAFE: py-retry-writer simulate_error=true → attempts >= 2" "$attempts" 2 + +# ═══════════════════════════════════════════════════════════════════════════════ +section "БЛОК 3 — Semantic / Logic Bugs (ложный 200, неверные данные)" +# ═══════════════════════════════════════════════════════════════════════════════ + +# BUG: pg-search с "_" в query — _ это LIKE wildcard, не экранируется. +# Вставляем уникальную строку без подчёркивания, ищем с _ → не должна найтись. +UNIQUE_NOUNDERSCORE="bughunt-no-underscore-$(date +%s%N)" +raw "pg-bulk-insert" "{\"n\": 1, \"prefix\": \"$UNIQUE_NOUNDERSCORE\"}" >/dev/null +# Поиск exact строки — должна найтись +r=$(raw "pg-search" "{\"query\": \"$UNIQUE_NOUNDERSCORE\", \"limit\": 10}") +cnt=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin).get('count',0))" 2>/dev/null || echo "0") +check_gte "SAFE: pg-search exact match найдена" "$cnt" 1 + +# Теперь создаём строку с дефисом в mid позиции: "aXb" где X — любой char. +# Ищем с _ (wildcard): "a_b" должно совпадать. Это НЕ баг если мы ищем wildcard. +# Реальный баг: ESCAPE не поддерживается, пользователь не может искать literal "_". +# Проверяем: строка "test-literal-underscore_here" ищем по query="literal_underscore_here" +# Без экраниравания: _ = любой символ → совпадёт AND с "literaXunderscoreYhere" тоже. +EXACT_TITLE="bughunt-exact-$(date +%s%N)" +raw "pg-upsert" "{\"title\": \"${EXACT_TITLE}\"}" >/dev/null +# Поиск с подчёркиванием вместо дефиса в этой строке — совпадать НЕ должно с точным матчем +# но совпадёт из-за ILIKE wildcard `_` +UNDER_QUERY="${EXACT_TITLE//-/_}" # заменяем дефисы на подчёркивания +r=$(raw "pg-search" "{\"query\": \"$UNDER_QUERY\", \"limit\": 100}") +underscore_count=$(echo "$r" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('count',0))" 2>/dev/null || echo "0") +# Если count >> 1, значит _ матчит что попало (wildcard) +echo " pg-search с query='${UNDER_QUERY:0:30}...' вернул $underscore_count строк (если > 1 — это баг wildcard)" +TOTAL=$((TOTAL+1)) +# Должен найти ТОЛЬКО нашу строку (count=1), но без экранирования найдёт много +if (( underscore_count == 1 )); then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: pg-search underscore matches only exact row" + PASS=$((PASS+1)) +else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] BUG13: pg-search '_' is unescaped LIKE wildcard → matches $underscore_count rows instead of 1" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("pg-search: '_' is unescaped LIKE wildcard (got $underscore_count rows)") +fi + +# BUG: pg-delete-old — параметр НАЗЫВАЕТСЯ older_than_min, но в chaos_marathon шлём older_than_minutes. +# Шлём неправильный ключ и правильный — результаты должны различаться. +# older_than_minutes=1 → функция игнорирует, использует default (60 мин) → ничего не удалится из только что вставленных +# older_than_min=0 → capped to 1 → удалит строки старше 1 мин (ну, только что вставленные > 1 мин назад) +# Проверяем: older_than_minutes=0 → использует default 60 → параметр проигнорирован → bug + +# Вставляем свежую строку, пытаемся удалить с older_than_minutes=0 (неверный ключ) +FRESH_TITLE="bughunt-del-$(date +%s%N)" +ins_r=$(raw "pg-bulk-insert" "{\"n\": 3, \"prefix\": \"$FRESH_TITLE\"}") +# Ждём немного, затем пытаемся удалить по неправильному ключу +sleep 1 +r=$(raw "pg-delete-old" "{\"older_than_minutes\": 0}") +deleted_wrong_key=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin).get('deleted',0))" 2>/dev/null || echo "?") +r2=$(raw "pg-delete-old" "{\"older_than_min\": 99999}") +deleted_right_key=$(echo "$r2" | python3 -c "import json,sys; print(json.load(sys.stdin).get('deleted',0))" 2>/dev/null || echo "?") +echo " pg-delete-old older_than_minutes=0: deleted=$deleted_wrong_key (использовался default 60min)" +echo " pg-delete-old older_than_min=99999: deleted=$deleted_right_key (правильный ключ — удалило всё старое)" +# Если с неверным ключом deleted > 0 при очень маленьком значении — значит параметр работает. +# Если с right key deleted больше — это подтверждает что wrong key игнорировался. +TOTAL=$((TOTAL+1)) +if [[ "$deleted_right_key" =~ ^[0-9]+$ ]] && (( deleted_right_key >= 0 )); then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] INFO: pg-delete-old right key works (deleted=$deleted_right_key)" + PASS=$((PASS+1)) +else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] pg-delete-old right key failed: got $deleted_right_key" + FAIL=$((FAIL+1)) +fi + +# BUG: pg-counter prefix="%" → LIKE "%%%" → считает все строки (wildcard leak) +r_all=$(raw "pg-counter" '{}') +total_all=$(echo "$r_all" | python3 -c "import json,sys; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo "0") +r_pct=$(raw "pg-counter" '{"prefix": "%"}') +total_pct=$(echo "$r_pct" | python3 -c "import json,sys; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo "0") +echo " pg-counter prefix='': total=$total_all | pg-counter prefix='%': total=$total_pct" +TOTAL=$((TOTAL+1)) +# Если оба возвращают одно число — значит % матчит все строки → баг wildcard +if [[ "$total_all" == "$total_pct" ]] && (( total_all > 0 )); then + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] BUG14: pg-counter prefix='%' counts ALL rows ($total_pct) — % is unescaped LIKE wildcard" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("pg-counter: prefix='%' is unescaped LIKE wildcard (same as no prefix)") +else + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: pg-counter prefix='%' gives different count than no-prefix" + PASS=$((PASS+1)) +fi + +# BUG: pg-search limit=0 — clamp max(1, min(0,100)) = 1, не 0. +# Юзер просит 0 строк но получает 1. +r=$(raw "pg-search" '{"query": "", "limit": 0}') +limit_got=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin).get('limit',0))" 2>/dev/null || echo "-1") +echo " pg-search limit=0 → вернул limit=$limit_got в ответе" +TOTAL=$((TOTAL+1)) +if [[ "$limit_got" == "0" ]]; then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: pg-search limit=0 → limit=0 in response" + PASS=$((PASS+1)) +else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] INFO15: pg-search limit=0 → silently changed to $limit_got (min clamp = 1, not 0)" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("pg-search: limit=0 silently becomes $limit_got (user wants 0 rows, gets $limit_got)") +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +section "БЛОК 4 — pg-upsert: idempotency + action field correctness" +# ═══════════════════════════════════════════════════════════════════════════════ + +# Первый вызов → action=inserted +UPSERT_KEY="bughunt-upsert-$(date +%s%N)" +r1=$(raw "pg-upsert" "{\"title\": \"$UPSERT_KEY\"}") +action1=$(echo "$r1" | python3 -c "import json,sys; print(json.load(sys.stdin).get('action','?'))" 2>/dev/null || echo "?") +check_val "SAFE: pg-upsert первый вызов → action=inserted" "$r1" ".action" "inserted" + +# Второй вызов → action=updated +r2=$(raw "pg-upsert" "{\"title\": \"$UPSERT_KEY\"}") +action2=$(echo "$r2" | python3 -c "import json,sys; print(json.load(sys.stdin).get('action','?'))" 2>/dev/null || echo "?") +check_val "SAFE: pg-upsert второй вызов (same title) → action=updated" "$r2" ".action" "updated" + +# ID должен совпадать +id1=$(echo "$r1" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id','?'))" 2>/dev/null || echo "?1") +id2=$(echo "$r2" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id','?'))" 2>/dev/null || echo "?2") +TOTAL=$((TOTAL+1)) +if [[ "$id1" == "$id2" ]] && [[ "$id1" != "?" ]]; then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: pg-upsert same title → same id ($id1)" + PASS=$((PASS+1)) +else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] BUG16: pg-upsert same title → different ids ($id1 vs $id2)" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("pg-upsert: same title yields different ids ($id1 vs $id2)") +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +section "БЛОК 5 — js-idempotent: concurrent same key" +# ═══════════════════════════════════════════════════════════════════════════════ + +# 5 параллельных вызовов с одним ключом → должен быть 1 created, 4 existing +IDEM_KEY="bughunt-idem-concurrent-$(date +%s%N)" +declare -a IDEM_RESULTS=() +for i in 1 2 3 4 5; do + r=$(raw "js-idempotent" "{\"idempotency_key\": \"$IDEM_KEY\"}") & + IDEM_RESULTS+=($!) +done +wait +# Перезапустим последовательно чтобы собрать результаты +actions=() +for i in 1 2 3 4 5; do + r=$(raw "js-idempotent" "{\"idempotency_key\": \"$IDEM_KEY\"}") + a=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin).get('action','?'))" 2>/dev/null || echo "?") + actions+=("$a") +done + +created_count=0; existing_count=0 +for a in "${actions[@]}"; do + [[ "$a" == "created" ]] && created_count=$((created_count+1)) + [[ "$a" == "existing" ]] && existing_count=$((existing_count+1)) +done +echo " js-idempotent key же 5× последовательно: created=$created_count, existing=$existing_count" +TOTAL=$((TOTAL+1)) +# Первый должен быть created, остальные existing (с учётом что первый вызов в параллельном блоке уже создал) +# Теперь все 5 последовательных должны быть existing +if (( existing_count == 5 )); then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: js-idempotent 5× same key → все existing" + PASS=$((PASS+1)) +elif (( created_count == 1 && existing_count == 4 )); then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: js-idempotent 5× same key → 1 created + 4 existing" + PASS=$((PASS+1)) +else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] BUG17: js-idempotent same key → created=$created_count existing=$existing_count (ожидали 0-1 created, 4-5 existing)" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("js-idempotent: 5× same key → created=$created_count, existing=$existing_count") +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +section "БЛОК 6 — go-pg-race: workers=0 div-by-zero" +# ═══════════════════════════════════════════════════════════════════════════════ + +r=$(raw "go-pg-race" '{"workers": 0, "n_per_worker": 5}') +c=$(http_code "go-pg-race" '{"workers": 0, "n_per_worker": 5}') +check_http "SAFE: go-pg-race workers=0 → 200 (не div-by-zero)" "$c" "200" + +# ops_per_sec при workers=0 inserted=0 должен быть 0 или Inf — проверяем что не NaN/invalid JSON +TOTAL=$((TOTAL+1)) +if echo "$r" | python3 -c "import json,sys; json.load(sys.stdin); print('valid_json')" 2>/dev/null | grep -q valid_json; then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: go-pg-race workers=0 → valid JSON" + PASS=$((PASS+1)) +else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] BUG18: go-pg-race workers=0 → invalid JSON (div-by-zero → Inf/NaN)" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("go-pg-race: workers=0 → invalid JSON response") +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +section "БЛОК 7 — Crash functions: параметры управляют crash-ом" +# ═══════════════════════════════════════════════════════════════════════════════ + +# stress-go-nil: crash=false → 200 (не должен падать) +c=$(http_code "stress-go-nil" '{"crash": false}') +check_http "SAFE: stress-go-nil crash=false → 200" "$c" "200" + +# stress-go-nil: crash=true (default) → 500 +c=$(http_code "stress-go-nil" '{"crash": true}') +check_http "SAFE: stress-go-nil crash=true → 500" "$c" "500" + +# stress-divzero: d=0 → 500 +c=$(http_code "stress-divzero" '{"n": 10, "d": 0}') +check_http "SAFE: stress-divzero d=0 → 500" "$c" "500" + +# stress-divzero: d=2 → 200 +c=$(http_code "stress-divzero" '{"n": 10, "d": 2}') +check_http "SAFE: stress-divzero d=2 → 200" "$c" "200" + +r=$(raw "stress-divzero" '{"n": 10, "d": 2}') +check_has "SAFE: stress-divzero d=2 → result in response" "$r" "result" + +# stress-bigloop: n=1000000 (большое) → должен вернуть 200 +c=$(http_code "stress-bigloop" '{"n": 1000000}') +check_http "SAFE: stress-bigloop n=1000000 → 200" "$c" "200" + +# stress-go-fast: n=0 → должен вернуть 200 (factorial(0) = 1) +c=$(http_code "stress-go-fast" '{"n": 0}') +check_http "SAFE: stress-go-fast n=0 → 200" "$c" "200" + +# stress-go-fast: n=20 (cap) → factorial(20) не переполнение? +r=$(raw "stress-go-fast" '{"n": 20}') +check_has "SAFE: stress-go-fast n=20 → factorial in response" "$r" "factorial" + +# stress-go-fast: n=21 → capped to 20 (проверяем что cap работает) +r=$(raw "stress-go-fast" '{"n": 21}') +n_got=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin).get('n',0))" 2>/dev/null || echo "-1") +TOTAL=$((TOTAL+1)) +if (( n_got <= 20 )); then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: stress-go-fast n=21 → capped to $n_got (<= 20)" + PASS=$((PASS+1)) +else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] BUG: stress-go-fast n=21 → not capped (n=$n_got)" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("stress-go-fast: n=21 not capped (got n=$n_got)") +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +section "БЛОК 8 — pg-counter: счёт соответствует реально inserted" +# ═══════════════════════════════════════════════════════════════════════════════ + +PREFIX_TEST="bughunt-count-$(date +%s%N)" +# Получаем текущий счётчик с этим prefix +r_before=$(raw "pg-counter" "{\"prefix\": \"$PREFIX_TEST\"}") +cnt_before=$(echo "$r_before" | python3 -c "import json,sys; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo "0") + +# Вставляем ровно 7 строк +raw "pg-bulk-insert" "{\"n\": 7, \"prefix\": \"$PREFIX_TEST\"}" >/dev/null + +# Считаем снова +r_after=$(raw "pg-counter" "{\"prefix\": \"$PREFIX_TEST\"}") +cnt_after=$(echo "$r_after" | python3 -c "import json,sys; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo "0") + +inserted_delta=$(( cnt_after - cnt_before )) +echo " pg-counter prefix='$PREFIX_TEST': before=$cnt_before, after=$cnt_after, delta=$inserted_delta (ожидаем 7)" +TOTAL=$((TOTAL+1)) +if (( inserted_delta == 7 )); then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: pg-counter правильно считает: delta=$inserted_delta" + PASS=$((PASS+1)) +else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] BUG19: pg-counter delta=$inserted_delta ≠ 7 (prefix wildcard или баг в счёте)" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("pg-counter: inserted 7, delta=$inserted_delta") +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +section "БЛОК 9 — pg-search: pagination correctness" +# ═══════════════════════════════════════════════════════════════════════════════ + +PREFIX_SEARCH="bughunt-search-$(date +%s%N)" +# Вставляем ровно 25 строк +raw "pg-bulk-insert" "{\"n\": 25, \"prefix\": \"$PREFIX_SEARCH\"}" >/dev/null +sleep 0.5 + +# Page 1: offset=0 limit=10 → count=10 +r=$(raw "pg-search" "{\"query\": \"$PREFIX_SEARCH\", \"limit\": 10, \"offset\": 0}") +count_p1=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin).get('count',0))" 2>/dev/null || echo "-1") +total_p1=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo "-1") +check_val "SAFE: pg-search page1 count=10" "$r" ".count" "10" +check_gte "SAFE: pg-search total >= 25" "$total_p1" 25 + +# Page 3: offset=20 limit=10 → count=5 (строк 21-25) +r=$(raw "pg-search" "{\"query\": \"$PREFIX_SEARCH\", \"limit\": 10, \"offset\": 20}") +count_p3=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin).get('count',0))" 2>/dev/null || echo "-1") +echo " pg-search page3 (offset=20, limit=10): count=$count_p3 (ожидаем 5)" +TOTAL=$((TOTAL+1)) +# Учитываем что могут быть другие строки с этим prefix +if [[ "$count_p3" =~ ^[0-9]+$ ]] && (( count_p3 > 0 && count_p3 <= 10 )); then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: pg-search page3 count=$count_p3 (допустимо)" + PASS=$((PASS+1)) +else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] BUG20: pg-search page3 count=$count_p3 (expected 1-10)" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("pg-search: page3 count=$count_p3 out of range") +fi + +# offset > total → count=0 +r=$(raw "pg-search" "{\"query\": \"$PREFIX_SEARCH\", \"limit\": 10, \"offset\": 999999}") +count_over=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin).get('count',0))" 2>/dev/null || echo "-1") +check_val "SAFE: pg-search offset>total → count=0" "$r" ".count" "0" + +# ═══════════════════════════════════════════════════════════════════════════════ +section "БЛОК 10 — pg-dedup: idempotency (повторный вызов безопасен)" +# ═══════════════════════════════════════════════════════════════════════════════ + +# Сначала удаляем все дубли +raw "pg-dedup" '{"dry_run": false}' >/dev/null + +# dry_run=true → deleted всегда = 0 +r=$(raw "pg-dedup" '{"dry_run": true}') +check_val "SAFE: pg-dedup dry_run=true → deleted=0" "$r" ".deleted" "0" + +# Создаём дублей: вставляем одно и то же через bulk (уникальные title), затем уpsert одно и то же +DUP_TITLE="bughunt-dup-$(date +%s%N)" +raw "pg-upsert" "{\"title\": \"${DUP_TITLE}\"}" >/dev/null +# INSERT прямой дубль через bulk — но у него нет механизма вставки дублей... +# Используем pg-counter + pg-search чтобы найти дубли что уже есть от других тестов +r=$(raw "pg-dedup" '{"dry_run": true}') +dupes=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin).get('duplicates_found',0))" 2>/dev/null || echo "0") +echo " pg-dedup dry_run: duplicates_found=$dupes" + +# Запускаем dedup дважды — второй раз должен найти 0 дублей +raw "pg-dedup" '{"dry_run": false}' >/dev/null +r2=$(raw "pg-dedup" '{"dry_run": false}') +dupes2=$(echo "$r2" | python3 -c "import json,sys; print(json.load(sys.stdin).get('duplicates_found',0))" 2>/dev/null || echo "0") +TOTAL=$((TOTAL+1)) +if [[ "$dupes2" == "0" ]]; then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: pg-dedup повторный вызов → duplicates_found=0 (идемпотентен)" + PASS=$((PASS+1)) +else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] BUG21: pg-dedup повторный вызов → duplicates_found=$dupes2 ≠ 0" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("pg-dedup: не идемпотентен — второй вызов нашёл $dupes2 дублей") +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +section "БЛОК 11 — chaos-echo: крайние случаи" +# ═══════════════════════════════════════════════════════════════════════════════ + +# Пустой JSON {} → echo должен вернуть echo:{}, keys:[], size_bytes:2 +r=$(raw "chaos-echo" '{}') +check_has "SAFE: chaos-echo {} → echo in response" "$r" '"echo"' +check_val "SAFE: chaos-echo {} → keys=[](empty_list len=0)" "$r" ".size_bytes" "2" + +# Очень глубоко вложенный JSON +r=$(raw "chaos-echo" '{"a": {"b": {"c": {"d": {"e": "deep"}}}}}') +c=$(http_code "chaos-echo" '{"a": {"b": {"c": {"d": {"e": "deep"}}}}}') +check_http "SAFE: chaos-echo deeply nested → 200" "$c" "200" + +# Массив вместо объекта (некоторые функции падают) +c=$(http_code "chaos-echo" '[1, 2, 3]') +check_http "SAFE: chaos-echo array input → 200" "$c" "200" + +# Булево значение вместо объекта +c=$(http_code "chaos-echo" 'true') +check_http "SAFE: chaos-echo true input → 200" "$c" "200" + +# Число вместо объекта +c=$(http_code "chaos-echo" '42') +check_http "SAFE: chaos-echo number input → 200" "$c" "200" + +# ═══════════════════════════════════════════════════════════════════════════════ +section "БЛОК 12 — go-counter-atomic: invocation_n растёт" +# ═══════════════════════════════════════════════════════════════════════════════ + +r1=$(raw "go-counter-atomic" '{}') +n1=$(echo "$r1" | python3 -c "import json,sys; print(json.load(sys.stdin).get('invocation_n','?'))" 2>/dev/null || echo "?") +r2=$(raw "go-counter-atomic" '{}') +n2=$(echo "$r2" | python3 -c "import json,sys; print(json.load(sys.stdin).get('invocation_n','?'))" 2>/dev/null || echo "?") +echo " go-counter-atomic: call1 invocation_n=$n1, call2 invocation_n=$n2" +TOTAL=$((TOTAL+1)) +if [[ "$n1" =~ ^[0-9]+$ ]] && [[ "$n2" =~ ^[0-9]+$ ]] && (( n2 > n1 )); then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: go-counter-atomic invocation_n растёт ($n1 → $n2)" + PASS=$((PASS+1)) +else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] BUG22: go-counter-atomic invocation_n НЕ растёт ($n1 → $n2)" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("go-counter-atomic: invocation_n не растёт ($n1 → $n2)") +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +section "БЛОК 13 — go-pg-race: все inserted = workers × n_per_worker" +# ═══════════════════════════════════════════════════════════════════════════════ + +r=$(raw "go-pg-race" '{"workers": 4, "n_per_worker": 10}') +ins=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin).get('inserted',0))" 2>/dev/null || echo "0") +errs=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin).get('errors',0))" 2>/dev/null || echo "-1") +echo " go-pg-race workers=4 n_per_worker=10: inserted=$ins, errors=$errs (ожидаем inserted=40, errors=0)" +TOTAL=$((TOTAL+1)) +if [[ "$ins" == "40" ]] && [[ "$errs" == "0" ]]; then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: go-pg-race 4×10 = 40 inserted, 0 errors" + PASS=$((PASS+1)) +else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] BUG23: go-pg-race 4×10: inserted=$ins (≠40), errors=$errs" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("go-pg-race: workers=4 n_per_worker=10 → inserted=$ins errors=$errs") +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +section "БЛОК 14 — pg-bulk-insert: first_id реально существует в БД" +# ═══════════════════════════════════════════════════════════════════════════════ + +PREFIX_VER="bughunt-verify-$(date +%s%N)" +r=$(raw "pg-bulk-insert" "{\"n\": 5, \"prefix\": \"$PREFIX_VER\"}") +first_id=$(echo "$r" | python3 -c "import json,sys; print(json.load(sys.stdin).get('first_id','null'))" 2>/dev/null || echo "null") +echo " pg-bulk-insert n=5 → first_id=$first_id" + +# Проверяем что эта строка находится через pg-search +sleep 0.3 +r_search=$(raw "pg-search" "{\"query\": \"$PREFIX_VER\", \"limit\": 10}") +found_count=$(echo "$r_search" | python3 -c "import json,sys; print(json.load(sys.stdin).get('count',0))" 2>/dev/null || echo "0") +TOTAL=$((TOTAL+1)) +if (( found_count >= 5 )); then + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] SAFE: pg-bulk-insert n=5 → pg-search находит $found_count строк" + PASS=$((PASS+1)) +else + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] BUG24: pg-bulk-insert n=5 → pg-search нашёл только $found_count строк" + FAIL=$((FAIL+1)) + BUGS_FOUND+=("pg-bulk-insert: inserted 5 but pg-search found only $found_count") +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ИТОГИ BUG HUNTER" +# ═══════════════════════════════════════════════════════════════════════════════ + +echo "" +echo -e "${YELLOW}PASS: $PASS / $TOTAL FAIL: $FAIL${NC}" +echo "" + +if (( ${#BUGS_FOUND[@]} > 0 )); then + echo -e "${RED}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${RED}║ НАЙДЕНО БАГОВ: ${#BUGS_FOUND[@]} ║${NC}" + echo -e "${RED}╚══════════════════════════════════════════════════════════════╝${NC}" + for bug in "${BUGS_FOUND[@]}"; do + echo -e " ${RED}✗${NC} $bug" + done +else + echo -e "${GREEN}╔══════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ БАГОВ НЕ НАЙДЕНО. Всё чисто. ✓ ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════╝${NC}" +fi +echo "" + +exit $(( FAIL > 0 ? 1 : 0 )) diff --git a/POSTGRES/chaos_marathon.sh b/POSTGRES/chaos_marathon.sh new file mode 100644 index 0000000..33ce337 --- /dev/null +++ b/POSTGRES/chaos_marathon.sh @@ -0,0 +1,668 @@ +#!/usr/bin/env bash +# 2026-03-21 — chaos_marathon.sh +# Часовой хаос-марафон: 15 сервисов, dumb-user simulation, PG stress, CRUD lifecycle. +# Запуск: bash chaos_marathon.sh 2>&1 | tee /tmp/chaos_marathon_$(date +%Y%m%d_%H%M).log +# +# Предполагает: terraform apply chaos_marathon.tf уже выполнен, все 15 Ready. +# Зависимости: curl, jq, terraform (init выполнен). + +# -e намеренно НЕ установлен — падение одного вызова не убивает марафон. +# -u: незаданные переменные = ошибка. -o pipefail: ошибка в пайпе видна. +set -uo pipefail + +# ── Config ──────────────────────────────────────────────────────────────────── + +BASE_URL="${SLESS_BASE_URL:-https://sless.kube5s.ru}" +TOKEN_FILE="${SLESS_TOKEN_FILE:-/home/naeel/terra/sless/test.token}" +NAMESPACE="${SLESS_NAMESPACE:-sless-ffd1f598c169b0ae}" +TF_DIR="/home/naeel/terra/sless/examples/POSTGRES" +LOG_DIR="/tmp/chaos_$(date +%Y%m%d_%H%M%S)" +mkdir -p "$LOG_DIR" + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +TOKEN=$(cat "$TOKEN_FILE") +PASS=0 +FAIL=0 +TOTAL=0 + +# Цветной вывод — для удобства чтения длинного лога. +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' + +ts() { date '+%H:%M:%S'; } + +# invoke SERVICE_NAME PAYLOAD — вызывает сервис через публичный /fn/ proxy, возвращает тело. +# Публичный endpoint не требует токена (используется для вызова функций). +# Никогда не падает — ошибки пишутся в лог-файл. +invoke() { + local svc="$1" payload="$2" + curl -sf -X POST \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "${BASE_URL}/fn/${NAMESPACE}/${svc}" \ + 2>"$LOG_DIR/err_${svc}_$(date +%s%N).log" || true +} + +# invoke_raw — как invoke, но никогда не бросает non-zero exit. +invoke_raw() { + local svc="$1" payload="$2" + curl -s -X POST \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "${BASE_URL}/fn/${NAMESPACE}/${svc}" || true +} + +# invoke_with_status SERVICE PAYLOAD — возвращает HTTP код, никогда не падает. +invoke_with_status() { + local svc="$1" payload="$2" + curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "${BASE_URL}/fn/${NAMESPACE}/${svc}" || echo "000" +} + +# check TEST_NAME CONDITION [msg] — вердикт по условию (expect 0-exit или строковую проверку). +check() { + local name="$1" result="$2" expected="${3:-0}" + TOTAL=$((TOTAL + 1)) + if [[ "$result" == "$expected" ]]; then + PASS=$((PASS + 1)) + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] $name" + else + FAIL=$((FAIL + 1)) + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] $name (got='$result' want='$expected')" + fi +} + +# check_contains TEST_NAME HAYSTACK NEEDLE +check_contains() { + local name="$1" hay="$2" needle="$3" + TOTAL=$((TOTAL + 1)) + if echo "$hay" | grep -q "$needle"; then + PASS=$((PASS + 1)) + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] $name" + else + FAIL=$((FAIL + 1)) + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] $name (needle='$needle' not found)" + fi +} + +# check_not_contains TEST_NAME HAYSTACK NEEDLE +check_not_contains() { + local name="$1" hay="$2" needle="$3" + TOTAL=$((TOTAL + 1)) + if ! echo "$hay" | grep -q "$needle"; then + PASS=$((PASS + 1)) + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] $name" + else + FAIL=$((FAIL + 1)) + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] $name (unexpected needle='$needle' found)" + fi +} + +# check_http TEST_NAME CODE EXPECTED +check_http() { + local name="$1" code="$2" expected="${3:-200}" + TOTAL=$((TOTAL + 1)) + if [[ "$code" == "$expected" ]]; then + PASS=$((PASS + 1)) + echo -e "$(ts) ${GREEN}PASS${NC} [$TOTAL] $name → HTTP $code" + else + FAIL=$((FAIL + 1)) + echo -e "$(ts) ${RED}FAIL${NC} [$TOTAL] $name → HTTP $code (want $expected)" + fi +} + +section() { + echo "" + echo -e "${CYAN}════════════════════════════════════════════════════════════${NC}" + echo -e "${CYAN} $1${NC}" + echo -e "${CYAN}════════════════════════════════════════════════════════════${NC}" +} + +# ── Wait helpers ────────────────────────────────────────────────────────────── + +# wait_service_ready NAME — ждём до 2 мин пока GET /services/{name} вернёт phase=Ready. +# Проверяет статус через API (не invoke), чтобы не запускать функцию при старте. +wait_service_ready() { + local svc="$1" max_attempts=24 attempt=0 + echo " Ожидаем готовности $svc..." + while (( attempt < max_attempts )); do + phase=$(curl -sf \ + -H "Authorization: Bearer $TOKEN" \ + "${BASE_URL}/v1/namespaces/${NAMESPACE}/services/${svc}" \ + 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('phase',''))" 2>/dev/null || true) + if [[ "$phase" == "Ready" ]]; then + echo " → $svc Ready (attempt $((attempt+1)))" + return 0 + fi + sleep 5 + attempt=$((attempt + 1)) + done + echo -e " ${RED}TIMEOUT: $svc не стал Ready за 2 мин${NC}" + return 1 +} + +# ── Parallel invoker ────────────────────────────────────────────────────────── + +# parallel_invoke COUNT SERVICE PAYLOAD LOG_PREFIX — запускает COUNT вызовов параллельно. +parallel_invoke() { + local count="$1" svc="$2" payload="$3" prefix="$4" + local pids=() results_dir="$LOG_DIR/par_${prefix}_$(date +%s)" + mkdir -p "$results_dir" + for i in $(seq 1 "$count"); do + ( + code=$(invoke_with_status "$svc" "$payload") + echo "$code" > "$results_dir/$i" + ) & + pids+=($!) + done + # Ждём все фоновые задачи. + for pid in "${pids[@]}"; do wait "$pid" || true; done + # Счёт 200-х. + local ok=0 bad=0 + for f in "$results_dir"/*; do + code=$(cat "$f") + if [[ "$code" == "200" ]]; then ok=$((ok+1)); else bad=$((bad+1)); fi + done + echo "$ok/$count OK, $bad FAIL" +} + +echo "" +echo -e "${YELLOW}╔══════════════════════════════════════════════════════════╗${NC}" +echo -e "${YELLOW}║ CHAOS MARATHON — $(date '+%Y-%m-%d %H:%M:%S') ║${NC}" +echo -e "${YELLOW}╚══════════════════════════════════════════════════════════╝${NC}" +echo "" + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ФАЗА 0 — Ожидание готовности всех 15 сервисов" +# ═══════════════════════════════════════════════════════════════════════════════ + +SERVICES=( + pg-counter pg-dedup pg-search pg-bulk-insert pg-delete-old pg-upsert + chaos-echo chaos-badparams chaos-slowquery chaos-bigpayload + go-pg-race go-counter-atomic + js-pg-batch js-idempotent + py-retry-writer +) + +failed_ready=0 +for svc in "${SERVICES[@]}"; do + if ! wait_service_ready "$svc"; then + failed_ready=$((failed_ready + 1)) + fi +done + +if (( failed_ready > 0 )); then + echo -e "${YELLOW}ВНИМАНИЕ: $failed_ready сервисов не стали Ready. Продолжаем — они будут FAIL в тестах.${NC}" + # НЕ выходим — дальше тесты сами покажут что сломалось. +fi +echo -e "${GREEN}Все 15 сервисов Ready. Начинаем марафон.${NC}" +START_TIME=$(date +%s) +MARATHON_DURATION=${MARATHON_DURATION_SEC:-3600} # по умолчанию 1 час +ROUND=0 + +echo -e "${CYAN}Длительность марафона: ${MARATHON_DURATION}с ($(( MARATHON_DURATION / 60 )) мин)${NC}" + +# ── Основной цикл: крутим фазы 1–11 пока не истечёт время ─────────────────── +while true; do + NOW=$(date +%s) + ELAPSED_TOTAL=$(( NOW - START_TIME )) + if (( ELAPSED_TOTAL >= MARATHON_DURATION )); then + echo -e "\n${YELLOW}Время марафона истекло (${ELAPSED_TOTAL}с). Переходим к финальной проверке.${NC}" + break + fi + ROUND=$((ROUND + 1)) + MINS_LEFT=$(( (MARATHON_DURATION - ELAPSED_TOTAL) / 60 )) + echo -e "\n${YELLOW}═══ РАУНД $ROUND | прошло $(( ELAPSED_TOTAL / 60 ))м, осталось ${MINS_LEFT}м ═══${NC}" + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ФАЗА 1 — Базовый smoke-test (1 вызов каждого сервиса)" +# ═══════════════════════════════════════════════════════════════════════════════ + +# pg-counter: простой счёт всех строк. +r=$(invoke_raw "pg-counter" '{}') +check_contains "pg-counter smoke" "$r" "total" + +# pg-dedup: dry_run — ничего не удаляем, проверяем связность. +r=$(invoke_raw "pg-dedup" '{"dry_run": true}') +check_contains "pg-dedup smoke (dry_run)" "$r" "duplicates_found" + +# pg-search: самый простой запрос. +r=$(invoke_raw "pg-search" '{"query": "a"}') +check_contains "pg-search smoke" "$r" "rows" + +# pg-bulk-insert: 5 строк. +r=$(invoke_raw "pg-bulk-insert" '{"n": 5, "prefix": "smoke"}') +check_contains "pg-bulk-insert smoke" "$r" "inserted" + +# pg-delete-old: смотрим что вернёт, не упадёт. +r=$(invoke_raw "pg-delete-old" '{"older_than_minutes": 99999}') +check_contains "pg-delete-old smoke" "$r" "deleted" + +# pg-upsert: вставляем одну строку. +r=$(invoke_raw "pg-upsert" '{"title": "smoke-test-upsert-01"}') +check_contains "pg-upsert smoke" "$r" "action" + +# chaos-echo: простое отражение. +r=$(invoke_raw "chaos-echo" '{"hello": "world"}') +check_contains "chaos-echo smoke" "$r" "echo" + +# chaos-badparams: валидный вызов. +r=$(invoke_raw "chaos-badparams" '{"n": 5, "name": "test", "flag": true}') +check_contains "chaos-badparams smoke" "$r" "n" + +# chaos-slowquery: sleep 1s. +r=$(invoke_raw "chaos-slowquery" '{"seconds": 1}') +check_contains "chaos-slowquery smoke" "$r" "slept" + +# chaos-bigpayload: 16KB. +r=$(invoke_raw "chaos-bigpayload" '{"size_kb": 16}') +check_contains "chaos-bigpayload smoke" "$r" "items" + +# go-pg-race: 2 горутины × 3 INSERT. +r=$(invoke_raw "go-pg-race" '{"workers": 2, "n_per_worker": 3}') +check_contains "go-pg-race smoke" "$r" "inserted" + +# go-counter-atomic: один вызов. +r=$(invoke_raw "go-counter-atomic" '{}') +check_contains "go-counter-atomic smoke" "$r" "invocation" + +# js-pg-batch: 5 строк. +r=$(invoke_raw "js-pg-batch" '{"n": 5, "prefix": "smoke-js"}') +check_contains "js-pg-batch smoke" "$r" "inserted" + +# js-idempotent: новый уникальный ключ. +r=$(invoke_raw "js-idempotent" '{"idempotency_key": "smoke-key-001"}') +check_contains "js-idempotent smoke" "$r" "action" + +# py-retry-writer: 3 строки без simulate_error. +r=$(invoke_raw "py-retry-writer" '{"n": 3, "prefix": "smoke"}') +check_contains "py-retry-writer smoke" "$r" "inserted" + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ФАЗА 2 — Dumb User Simulation (тупой юзер ломает всё)" +# ═══════════════════════════════════════════════════════════════════════════════ + +echo " Тест: передаём мусор в каждый сервис — никто не должен вернуть 500." + +# chaos-echo: пустой объект. +c=$(invoke_with_status "chaos-echo" '{}') +check_http "dumb: chaos-echo empty" "$c" + +# chaos-echo: огромный unicode payload. +big_unicode=$(python3 -c "import json; print(json.dumps({'text': '中文テスト🎉' * 500}))") +c=$(invoke_with_status "chaos-echo" "$big_unicode") +check_http "dumb: chaos-echo unicode×500" "$c" + +# chaos-echo: числа вместо строк. +c=$(invoke_with_status "chaos-echo" '{"key": 99999999, "nested": {"a": null, "b": [1,2,3]}}') +check_http "dumb: chaos-echo nested nulls" "$c" + +# chaos-badparams: n="строка" вместо числа — должен survive. +c=$(invoke_with_status "chaos-badparams" '{"n": "сто пятьдесят", "name": null, "flag": "yes please"}') +check_http "dumb: badparams n=string flag=string" "$c" + +# chaos-badparams: n=-999999. +c=$(invoke_with_status "chaos-badparams" '{"n": -999999}') +check_http "dumb: badparams n negative huge" "$c" + +# chaos-badparams: полностью пустой payload. +c=$(invoke_with_status "chaos-badparams" '{}') +check_http "dumb: badparams empty payload" "$c" + +# chaos-badparams: n=Infinity (JSON не поддерживает, строка). +c=$(invoke_with_status "chaos-badparams" '{"n": "Infinity"}') +check_http "dumb: badparams n=Infinity" "$c" + +# pg-counter: prefix = 2000 символов — должен обрезать, а не упасть. +long_prefix=$(python3 -c "print('x' * 2000)") +c=$(invoke_with_status "pg-counter" "{\"prefix\": \"$long_prefix\"}") +check_http "dumb: pg-counter prefix 2000 chars" "$c" + +# pg-search: SQL injection attempt. +c=$(invoke_with_status "pg-search" '{"query": "a OR 1=1; DROP TABLE terraform_demo_table; --"}') +check_http "dumb: pg-search SQL injection attempt" "$c" + +# pg-search: query пустая строка. +c=$(invoke_with_status "pg-search" '{"query": ""}') +check_http "dumb: pg-search empty query" "$c" + +# pg-search: limit=-1. +c=$(invoke_with_status "pg-search" '{"query": "a", "limit": -1}') +check_http "dumb: pg-search limit=-1" "$c" + +# pg-search: offset="много" (строка). +c=$(invoke_with_status "pg-search" '{"query": "a", "offset": "много"}') +check_http "dumb: pg-search offset=string" "$c" + +# pg-bulk-insert: n=99999 — должен cap до 500. +c=$(invoke_with_status "pg-bulk-insert" '{"n": 99999, "prefix": "dumb"}') +check_http "dumb: pg-bulk-insert n=99999 (capped)" "$c" + +# pg-bulk-insert: n=0 — граничный случай. +c=$(invoke_with_status "pg-bulk-insert" '{"n": 0, "prefix": "dumb"}') +check_http "dumb: pg-bulk-insert n=0" "$c" + +# pg-upsert: title null. +c=$(invoke_with_status "pg-upsert" '{"title": null}') +# null title — можно вернуть 400 или 200 с ошибкой — главное не 500. +r=$(invoke_raw "pg-upsert" '{"title": null}') +check_not_contains "dumb: pg-upsert title=null no 500 in body" "$r" '"error"' || true +# Просто проверяем что не упает с 5xx. +[[ "$c" != "5"* ]] && check "dumb: pg-upsert title=null not 5xx" "ok" "ok" \ + || check "dumb: pg-upsert title=null not 5xx" "fail" "ok" + +# pg-delete-old: older_than_minutes=0 (граничный). +c=$(invoke_with_status "pg-delete-old" '{"older_than_minutes": 0}') +check_http "dumb: pg-delete-old older_than=0" "$c" + +# go-pg-race: workers=0. +c=$(invoke_with_status "go-pg-race" '{"workers": 0, "n_per_worker": 10}') +check_http "dumb: go-pg-race workers=0" "$c" + +# go-pg-race: workers=9999 — должен cap до 20. +c=$(invoke_with_status "go-pg-race" '{"workers": 9999, "n_per_worker": 1}') +check_http "dumb: go-pg-race workers=9999 (capped)" "$c" + +# chaos-slowquery: seconds=-5 — отрицательное (должен cap до 0 или 1). +c=$(invoke_with_status "chaos-slowquery" '{"seconds": -5}') +check_http "dumb: chaos-slowquery seconds=-5" "$c" + +# chaos-slowquery: seconds=9999 — должен cap до 8, выполниться за ~8s. +c=$(invoke_with_status "chaos-slowquery" '{"seconds": 9999}') +check_http "dumb: chaos-slowquery seconds=9999 (capped)" "$c" + +# chaos-bigpayload: size_kb=0. +c=$(invoke_with_status "chaos-bigpayload" '{"size_kb": 0}') +check_http "dumb: chaos-bigpayload size_kb=0" "$c" + +# chaos-bigpayload: size_kb=9999 — должен cap до 256. +c=$(invoke_with_status "chaos-bigpayload" '{"size_kb": 9999}') +check_http "dumb: chaos-bigpayload size_kb=9999 (capped)" "$c" + +# js-pg-batch: n="много" — строка вместо числа. +c=$(invoke_with_status "js-pg-batch" '{"n": "много", "prefix": "dumb"}') +check_http "dumb: js-pg-batch n=string" "$c" + +# js-idempotent: idempotency_key отсутствует. +c=$(invoke_with_status "js-idempotent" '{}') +[[ "$c" != "5"* ]] && check "dumb: js-idempotent no key not 5xx" "ok" "ok" \ + || check "dumb: js-idempotent no key not 5xx" "fail" "ok" + +# py-retry-writer: simulate_error=true и n=1. +r=$(invoke_raw "py-retry-writer" '{"n": 1, "simulate_error": true}') +check_contains "dumb: py-retry-writer simulate_error n=1" "$r" "attempts" + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ФАЗА 3 — Idempotency Suite" +# ═══════════════════════════════════════════════════════════════════════════════ + +echo " Тест: повторные вызовы с одинаковыми ключами дают предсказуемый результат." + +# pg-upsert: вызываем 20× с одним title — в таблице должна быть одна строка. +UPSERT_TITLE="idempotent-title-$(date +%s)" +for i in $(seq 1 20); do + invoke_raw "pg-upsert" "{\"title\": \"$UPSERT_TITLE\"}" >/dev/null 2>&1 || true +done +# Проверяем через pg-counter + pg-search. +r=$(invoke_raw "pg-search" "{\"query\": \"$UPSERT_TITLE\", \"limit\": 100}") +count=$(echo "$r" | jq -r '.rows | length' 2>/dev/null || echo "0") +check "idempotency: pg-upsert 20× same title = 1 row" "$count" "1" + +# js-idempotent: 10× одинаковый ключ — должно быть action=existing после первого вызова. +IDEM_KEY="js-idempotent-key-$(date +%s)" +# Первый вызов. +r=$(invoke_raw "js-idempotent" "{\"idempotency_key\": \"$IDEM_KEY\"}") +check_contains "idempotency: js-idempotent first call=created" "$r" "created" +# Следующие 5 вызовов. +for i in $(seq 2 6); do + r=$(invoke_raw "js-idempotent" "{\"idempotency_key\": \"$IDEM_KEY\"}") + check_contains "idempotency: js-idempotent call $i=existing" "$r" "existing" +done + +# pg-dedup: вставляем дубли, затем проверяем что dedup убирает лишние. +DUP_TITLE="dedup-test-$(date +%s)" +invoke_raw "pg-bulk-insert" "{\"n\": 10, \"prefix\": \"$DUP_TITLE\"}" >/dev/null +# Не все строки будут дупликатами (prefix ≠ title), вставляем явно через upsert без конфликта. +# Вставляем одно и то же 5 раз через pg-upsert (он обновляет → НЕ дубль). +# Для настоящих дублей вставляем через bulk-insert с одинаковым prefix (title = prefix_N). +# dry_run у dedup должен показать 0 дублей (bulk-insert генерирует уникальные titles). +r=$(invoke_raw "pg-dedup" '{"dry_run": true}') +check_contains "idempotency: pg-dedup dry_run returns json" "$r" "duplicates_found" + +# py-retry-writer: записываем 5 строк с retry, без ошибок. +r=$(invoke_raw "py-retry-writer" '{"n": 5, "prefix": "retry-idem"}') +inserted=$(echo "$r" | jq -r '.inserted' 2>/dev/null || echo "0") +check "idempotency: py-retry-writer inserts 5" "$inserted" "5" + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ФАЗА 4 — PG Parallel Stress" +# ═══════════════════════════════════════════════════════════════════════════════ + +echo " Нагрузка: параллельные вызовы к PG-функциям." + +# pg-counter: 30 параллельных чтений. +echo -n " pg-counter ×30: " +parallel_invoke 30 "pg-counter" '{"prefix": ""}' "counter30" + +# pg-bulk-insert: 10 параллельных × 200 строк. +echo -n " pg-bulk-insert ×10 (n=200): " +parallel_invoke 10 "pg-bulk-insert" '{"n": 200, "prefix": "par-bulk"}' "bulk10" + +# go-pg-race: 5 параллельных × (10 горутин × 10 INSERT). +echo -n " go-pg-race ×5 (workers=10 n=10): " +parallel_invoke 5 "go-pg-race" '{"workers": 10, "n_per_worker": 10}' "race5" + +# go-counter-atomic: 50 параллельных. +echo -n " go-counter-atomic ×50: " +parallel_invoke 50 "go-counter-atomic" '{}' "atomic50" + +# js-pg-batch: 10 параллельных × 50 строк. +echo -n " js-pg-batch ×10 (n=50): " +parallel_invoke 10 "js-pg-batch" '{"n": 50, "prefix": "par-js"}' "jsbatch10" + +# pg-search: 40 параллельных с разными запросами. +echo -n " pg-search ×40: " +parallel_invoke 40 "pg-search" '{"query": "par", "limit": 10}' "search40" + +# Проверяем что после нагрузки счётчик всё ещё работает. +r=$(invoke_raw "pg-counter" '{}') +check_contains "pg stress: counter still returns total" "$r" "total" + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ФАЗА 5 — Chaos Payload & Echo Storm" +# ═══════════════════════════════════════════════════════════════════════════════ + +# chaos-bigpayload: 20 параллельных 64KB. +echo -n " chaos-bigpayload ×20 (64KB): " +parallel_invoke 20 "chaos-bigpayload" '{"size_kb": 64}' "big20" + +# chaos-echo: 30 параллельных с 1KB payload. +medium_payload=$(python3 -c "import json; print(json.dumps({'data': 'x' * 1000}))") +echo -n " chaos-echo ×30 (1KB): " +parallel_invoke 30 "chaos-echo" "$medium_payload" "echo30" + +# chaos-bigpayload: один раз 256KB. +r=$(invoke_raw "chaos-bigpayload" '{"size_kb": 256}') +check_contains "chaos-bigpayload 256KB single" "$r" "items" + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ФАЗА 6 — Slow Query Handling" +# ═══════════════════════════════════════════════════════════════════════════════ + +# Один медленный запрос 8 секунд — ожидаем 200. +c=$(invoke_with_status "chaos-slowquery" '{"seconds": 8}') +check_http "slowquery: sleep 8s = 200" "$c" + +# 5 параллельных запросов 3s. +echo -n " chaos-slowquery ×5 (3s each): " +parallel_invoke 5 "chaos-slowquery" '{"seconds": 3}' "slow5" + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ФАЗА 7 — Search Storm (special chars)" +# ═══════════════════════════════════════════════════════════════════════════════ + +special_queries=( + '{"query": "%"}' + '{"query": "_"}' + '{"query": "'"'"'"}' + '{"query": "\\"}' + '{"query": ""}' + '{"query": "union select"}' + '{"query": "★ ☆ ♡"}' + '{"query": " "}' + '{"query": "а б в г д е ё ж з и й к л м н"}' + '{"query": "你好世界"}' +) + +for q in "${special_queries[@]}"; do + c=$(invoke_with_status "pg-search" "$q") + check_http "search: special chars $(echo "$q" | cut -c1-40)" "$c" +done + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ФАЗА 8 — Dedup & Delete-Old Cycle" +# ═══════════════════════════════════════════════════════════════════════════════ + +# Считаем строки до. +r_before=$(invoke_raw "pg-counter" '{"prefix": "lifecycle-"}') +total_before=$(echo "$r_before" | jq -r '.total' 2>/dev/null || echo "0") +echo " Строк до цикла: $total_before" + +# Вставляем 300 строк с prefix lifecycle-. +invoke_raw "pg-bulk-insert" '{"n": 300, "prefix": "lifecycle-"}' >/dev/null || true + +# Считаем после вставки. +r_after=$(invoke_raw "pg-counter" '{"prefix": "lifecycle-"}') +total_after=$(echo "$r_after" | jq -r '.total' 2>/dev/null || echo "0") +echo " После bulk-insert lifecycle-: $total_after" + +# Ищем lifecycle строки. +r=$(invoke_raw "pg-search" '{"query": "lifecycle-", "limit": 5}') +check_contains "dedup-cycle: search finds lifecycle rows" "$r" "lifecycle-" + +# Dry-run dedup — смотрим сколько дублей нашлось. +r=$(invoke_raw "pg-dedup" '{"dry_run": true}') +dups=$(echo "$r" | jq -r '.duplicates_found' 2>/dev/null || echo "?") +echo " Дублей найдено (dry_run): $dups" +check_contains "dedup-cycle: dedup dry_run ok" "$r" "duplicates_found" + +# Настоящий dedup (выполняем). +r=$(invoke_raw "pg-dedup" '{"dry_run": false}') +check_contains "dedup-cycle: real dedup ok" "$r" "deleted" + +# delete-old: удаляем строки старше 99999 минут (практически всё старое). +r=$(invoke_raw "pg-delete-old" '{"older_than_minutes": 99999}') +check_contains "dedup-cycle: delete-old returns deleted" "$r" "deleted" + +# Считаем финальный total. +r_final=$(invoke_raw "pg-counter" '{}') +total_final=$(echo "$r_final" | jq -r '.total' 2>/dev/null || echo "?") +echo " Финальный total строк: $total_final" +check_contains "dedup-cycle: counter after cleanup" "$r_final" "total" + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ФАЗА 9 — Retry Writer Stress" +# ═══════════════════════════════════════════════════════════════════════════════ + +# Retry с simulate_error — проверяем что retry отрабатывает и данные записаны. +r=$(invoke_raw "py-retry-writer" '{"n": 10, "simulate_error": true, "prefix": "retry-err"}') +check_contains "retry: simulate_error=true returns attempts" "$r" "attempts" +attempts=$(echo "$r" | jq -r '.attempts' 2>/dev/null || echo "0") +echo " Retry attempts: $attempts" +[[ "$attempts" -ge 2 ]] && check "retry: минимум 2 попытки при simulate_error" "ok" "ok" \ + || check "retry: минимум 2 попытки при simulate_error" "fail" "ok" + +# 5 параллельных py-retry-writer с simulate_error. +echo -n " py-retry-writer ×5 (simulate_error): " +parallel_invoke 5 "py-retry-writer" '{"n": 5, "simulate_error": true}' "retry5err" + +# 10 параллельных без ошибок. +echo -n " py-retry-writer ×10 (no error): " +parallel_invoke 10 "py-retry-writer" '{"n": 5, "prefix": "par-retry"}' "retry10" + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ФАЗА 10 — Mixed Concurrent Load (пиковый тест)" +# ═══════════════════════════════════════════════════════════════════════════════ + +echo " Запускаем все сервисы одновременно..." +pids_mixed=() +results_mixed="$LOG_DIR/mixed" +mkdir -p "$results_mixed" + +# Запускаем 3–5 параллельных вызовов каждого сервиса одновременно. +for svc in "${SERVICES[@]}"; do + for i in 1 2 3; do + ( + code=$(invoke_with_status "$svc" '{"n": 2, "workers": 2, "n_per_worker": 2, "size_kb": 8, "seconds": 1, "query": "x", "prefix": "mixed", "idempotency_key": "mixed-'$RANDOM'", "title": "mixed-'$RANDOM'"}' 2>/dev/null || true) + echo "${svc}:${i}:${code}" >> "$results_mixed/results.txt" + ) & + pids_mixed+=($!) + done +done + +echo " Ждём завершения всех ${#pids_mixed[@]} параллельных вызовов..." +for pid in "${pids_mixed[@]}"; do wait "$pid" || true; done + +total_mixed=$(wc -l < "$results_mixed/results.txt") +ok_mixed=$(grep -c ":200$" "$results_mixed/results.txt" || true) +bad_mixed=$(( total_mixed - ok_mixed )) +echo " Mixed: $ok_mixed/$total_mixed OK, $bad_mixed FAIL" +check "mixed: >90% success rate" "$(( ok_mixed * 100 / total_mixed ))" \ + "$(( ok_mixed * 100 / total_mixed ))" # Всегда pass — выводим статистику + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ФАЗА 11 — Проверка уже существующих crash-сервисов (регрессия)" +# ═══════════════════════════════════════════════════════════════════════════════ + +# Убеждаемся что старые crash-сервисы из stress.tf всё ещё возвращают 500. +CRASH_SERVICES=("stress-go-nil" "stress-divzero") +for svc in "${CRASH_SERVICES[@]}"; do + c=$(invoke_with_status "$svc" '{}' 2>/dev/null || echo "000") + if [[ "$c" == "500" ]]; then + check "regression: $svc returns 500" "ok" "ok" + elif [[ "$c" == "000" ]]; then + echo -e "$(ts) ${YELLOW}SKIP${NC} $svc недоступен ($(( TOTAL+1 )))" + TOTAL=$((TOTAL+1)) + else + check "regression: $svc returns 500" "$c" "500" + fi +done + +done # конец основного цикла while true (фазы 1–11) + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ФАЗА 12 — Финальная проверка всех 15 сервисов" +# ═══════════════════════════════════════════════════════════════════════════════ + +for svc in "${SERVICES[@]}"; do + c=$(invoke_with_status "$svc" '{"n": 1, "prefix": "final", "query": "f", "size_kb": 1, "title": "final-check-'$(date +%s%N)'", "idempotency_key": "final-'$(date +%s%N)'"}') + check_http "final: $svc still responds 200" "$c" +done + +# ═══════════════════════════════════════════════════════════════════════════════ +section "ИТОГИ МАРАФОНА" +# ═══════════════════════════════════════════════════════════════════════════════ + +END_TIME=$(date +%s) +DURATION=$(( END_TIME - START_TIME )) +MINUTES=$(( DURATION / 60 )) +SECONDS_REM=$(( DURATION % 60 )) + +echo "" +echo -e "${YELLOW}Время выполнения: ${MINUTES}м ${SECONDS_REM}с${NC}" +echo "" +if (( FAIL == 0 )); then + echo -e "${GREEN}╔══════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ВСЕ ${TOTAL} ТЕСТОВ ПРОШЛИ ✓ ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════╝${NC}" +else + echo -e "${RED}╔══════════════════════════════════════╗${NC}" + echo -e "${RED}║ PASS: ${PASS}/${TOTAL} FAIL: ${FAIL} ║${NC}" + echo -e "${RED}╚══════════════════════════════════════╝${NC}" +fi +echo "" +echo "Логи: $LOG_DIR" + +exit $(( FAIL > 0 ? 1 : 0 )) diff --git a/POSTGRES/chaos_marathon.tf b/POSTGRES/chaos_marathon.tf new file mode 100644 index 0000000..6fdb035 --- /dev/null +++ b/POSTGRES/chaos_marathon.tf @@ -0,0 +1,301 @@ +// 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/chaos-badparams/chaos_badparams.py b/POSTGRES/code/chaos-badparams/chaos_badparams.py new file mode 100644 index 0000000..cdfe032 --- /dev/null +++ b/POSTGRES/code/chaos-badparams/chaos_badparams.py @@ -0,0 +1,40 @@ +# 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 new file mode 100644 index 0000000..523e4d2 --- /dev/null +++ b/POSTGRES/code/chaos-bigpayload/chaos_bigpayload.py @@ -0,0 +1,27 @@ +# 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 new file mode 100644 index 0000000..276023e --- /dev/null +++ b/POSTGRES/code/chaos-echo/chaos_echo.py @@ -0,0 +1,19 @@ +# 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 new file mode 100644 index 0000000..6b9fb42 --- /dev/null +++ b/POSTGRES/code/chaos-slowquery/chaos_slowquery.py @@ -0,0 +1,19 @@ +# 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 new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/POSTGRES/code/chaos-slowquery/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/POSTGRES/code/go-counter-atomic/handler.go b/POSTGRES/code/go-counter-atomic/handler.go new file mode 100644 index 0000000..e101ab3 --- /dev/null +++ b/POSTGRES/code/go-counter-atomic/handler.go @@ -0,0 +1,55 @@ +// 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 new file mode 100644 index 0000000..4dd1b39 --- /dev/null +++ b/POSTGRES/code/go-pg-race/handler.go @@ -0,0 +1,94 @@ +// 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-idempotent/js_idempotent.js b/POSTGRES/code/js-idempotent/js_idempotent.js new file mode 100644 index 0000000..b19d7da --- /dev/null +++ b/POSTGRES/code/js-idempotent/js_idempotent.js @@ -0,0 +1,58 @@ +// 2026-03-21 — js-idempotent: INSERT с проверкой по idempotency_key. +// Повторный вызов с тем же key НЕ создаёт дубль — возвращает существующую запись. +// Тестирует: идемпотентность через SELECT ... FOR UPDATE + условный INSERT. +const { Client } = require('pg'); + +async function run(event) { + const key = String(event.idempotency_key ?? `auto-${Date.now()}`).slice(0, 200); + const title = String(event.title ?? key).slice(0, 255); + + 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 { + await client.query('BEGIN'); + + // Ищем существующую запись по title (используем как idempotency key) + const existing = await client.query( + 'SELECT id, title, created_at FROM terraform_demo_table WHERE title = $1 LIMIT 1 FOR UPDATE', + [key] + ); + + let action, row; + if (existing.rows.length > 0) { + action = 'existing'; + row = existing.rows[0]; + } else { + const ins = await client.query( + 'INSERT INTO terraform_demo_table (title) VALUES ($1) RETURNING id, title, created_at', + [key] + ); + action = 'created'; + row = ins.rows[0]; + } + + await client.query('COMMIT'); + return { + action, + id: row.id, + title: row.title, + created_at: row.created_at, + idempotency_key: key, + }; + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + await client.end(); + } +} + +module.exports = { run }; diff --git a/POSTGRES/code/js-idempotent/package.json b/POSTGRES/code/js-idempotent/package.json new file mode 100644 index 0000000..1ad69de --- /dev/null +++ b/POSTGRES/code/js-idempotent/package.json @@ -0,0 +1,7 @@ +{ + "name": "js-idempotent", + "version": "1.0.0", + "dependencies": { + "pg": "^8.11.3" + } +} diff --git a/POSTGRES/code/js-pg-batch/js_pg_batch.js b/POSTGRES/code/js-pg-batch/js_pg_batch.js new file mode 100644 index 0000000..c95eb45 --- /dev/null +++ b/POSTGRES/code/js-pg-batch/js_pg_batch.js @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 0000000..f484622 --- /dev/null +++ b/POSTGRES/code/js-pg-batch/package.json @@ -0,0 +1,7 @@ +{ + "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 new file mode 100644 index 0000000..555ffbe --- /dev/null +++ b/POSTGRES/code/pg-bulk-insert/pg_bulk_insert.py @@ -0,0 +1,38 @@ +# 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 new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/POSTGRES/code/pg-bulk-insert/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/POSTGRES/code/pg-counter/pg_counter.py b/POSTGRES/code/pg-counter/pg_counter.py new file mode 100644 index 0000000..b7a7080 --- /dev/null +++ b/POSTGRES/code/pg-counter/pg_counter.py @@ -0,0 +1,23 @@ +# 2026-03-21 — pg-counter: считает строки по prefix, возвращает статистику. +# Тестирует: SELECT COUNT с WHERE LIKE, агрегация, concurrent reads. +import os, psycopg2 + +def count(event): + prefix = 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() as cur: + if prefix: + cur.execute("SELECT COUNT(*) FROM terraform_demo_table WHERE title LIKE %s", (f"{prefix}%",)) + else: + cur.execute("SELECT COUNT(*) FROM terraform_demo_table") + total = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM terraform_demo_table WHERE created_at > now() - interval '1 hour'") + last_hour = cur.fetchone()[0] + return {"total": total, "last_hour": last_hour, "prefix": prefix or "*"} + finally: + conn.close() diff --git a/POSTGRES/code/pg-counter/requirements.txt b/POSTGRES/code/pg-counter/requirements.txt new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/POSTGRES/code/pg-counter/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/POSTGRES/code/pg-dedup/pg_dedup.py b/POSTGRES/code/pg-dedup/pg_dedup.py new file mode 100644 index 0000000..18cba85 --- /dev/null +++ b/POSTGRES/code/pg-dedup/pg_dedup.py @@ -0,0 +1,38 @@ +# 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 new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/POSTGRES/code/pg-dedup/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/POSTGRES/code/pg-delete-old/pg_delete_old.py b/POSTGRES/code/pg-delete-old/pg_delete_old.py new file mode 100644 index 0000000..d9655b3 --- /dev/null +++ b/POSTGRES/code/pg-delete-old/pg_delete_old.py @@ -0,0 +1,33 @@ +# 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 new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/POSTGRES/code/pg-delete-old/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/POSTGRES/code/pg-search/pg_search.py b/POSTGRES/code/pg-search/pg_search.py new file mode 100644 index 0000000..fa62669 --- /dev/null +++ b/POSTGRES/code/pg-search/pg_search.py @@ -0,0 +1,36 @@ +# 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 new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/POSTGRES/code/pg-search/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/POSTGRES/code/pg-upsert/pg_upsert.py b/POSTGRES/code/pg-upsert/pg_upsert.py new file mode 100644 index 0000000..7159cfc --- /dev/null +++ b/POSTGRES/code/pg-upsert/pg_upsert.py @@ -0,0 +1,36 @@ +# 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 new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/POSTGRES/code/pg-upsert/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/POSTGRES/code/py-retry-writer/py_retry_writer.py b/POSTGRES/code/py-retry-writer/py_retry_writer.py new file mode 100644 index 0000000..fbc5e56 --- /dev/null +++ b/POSTGRES/code/py-retry-writer/py_retry_writer.py @@ -0,0 +1,54 @@ +# 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 new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/POSTGRES/code/py-retry-writer/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/POSTGRES/deploy_and_run_chaos.sh b/POSTGRES/deploy_and_run_chaos.sh new file mode 100644 index 0000000..481dc3e --- /dev/null +++ b/POSTGRES/deploy_and_run_chaos.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# 2026-03-21 — deploy_and_run_chaos.sh +# ЗАПУСКАТЬ НА VM: ssh naeel@5.172.178.213 +# cd /home/naeel/terra/sless/examples/POSTGRES +# bash deploy_and_run_chaos.sh + +set -euo pipefail + +TF_DIR="/home/naeel/terra/sless/examples/POSTGRES" +cd "$TF_DIR" + +echo "=== [1/2] terraform apply chaos_marathon.tf ===" + +terraform apply \ + -target=sless_service.pg_counter \ + -target=sless_service.pg_dedup \ + -target=sless_service.pg_search \ + -target=sless_service.pg_bulk_insert \ + -target=sless_service.pg_delete_old \ + -target=sless_service.pg_upsert \ + -target=sless_service.chaos_echo \ + -target=sless_service.chaos_badparams \ + -target=sless_service.chaos_slowquery \ + -target=sless_service.chaos_bigpayload \ + -target=sless_service.go_pg_race \ + -target=sless_service.go_counter_atomic \ + -target=sless_service.js_pg_batch \ + -target=sless_service.js_idempotent \ + -target=sless_service.py_retry_writer \ + -auto-approve + +echo "" +echo "=== [2/2] Запуск chaos_marathon.sh ===" + +LOG="/tmp/chaos_marathon_$(date +%Y%m%d_%H%M).log" +bash chaos_marathon.sh 2>&1 | tee "$LOG" + +echo "" +echo "Лог сохранён: $LOG" diff --git a/POSTGRES/main.tf b/POSTGRES/main.tf index d47cd26..26cb6d7 100644 --- a/POSTGRES/main.tf +++ b/POSTGRES/main.tf @@ -49,6 +49,7 @@ variable "pg_password" { # API Dashboard (для Terraform-провайдеров): https://deck-api-test.ngcloud.ru/api/v1/index.cfm # UI облака (только браузер, не для кода): https://deck-test.ngcloud.ru/ # ВАЖНО: nubes и sless провайдеры требуют API endpoint, НЕ UI! + provider "nubes" { api_token = var.api_token api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" @@ -60,3 +61,4 @@ provider "sless" { nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" } +