feat(web-console): create/edit/delete functions in UI, v0.1.4
- services/funcs: новые маршруты POST create-function, POST save-function, DELETE delete-function — создание/редактирование/удаление через браузер - index.html: модалка создания с шаблонами Python/Node.js hello world, кнопки edit/delete на каждой карточке функции - sless-funcs-service:v0.1.4 задеплоен - examples/POSTGRES: удалены логи, .bak, backup tfstate, лишние функции оставлены 2 Python (pg-stats, pg-counter) + 2 Node.js (pg-info, js-idempotent)
This commit is contained in:
@@ -1,40 +0,0 @@
|
||||
# 2026-03-21 — chaos-badparams: проверяет что функция не падает на мусорных входных данных.
|
||||
# Принимает type=missing|wrong_type|huge|negative|zero и возвращает safe-ответ.
|
||||
# Тестирует: устойчивость к "тупому юзеру" — никакого 500 на плохих входных данных.
|
||||
import json
|
||||
|
||||
_MAX_N = 10_000
|
||||
|
||||
def validate(event):
|
||||
errors = []
|
||||
results = {}
|
||||
|
||||
# n: должно быть int от 1 до MAX_N
|
||||
raw_n = event.get("n")
|
||||
try:
|
||||
n = int(raw_n)
|
||||
if n <= 0:
|
||||
errors.append(f"n must be > 0, got {n}")
|
||||
n = 1
|
||||
elif n > _MAX_N:
|
||||
errors.append(f"n capped from {n} to {_MAX_N}")
|
||||
n = _MAX_N
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"n is not a valid int: {repr(raw_n)}, using default 1")
|
||||
n = 1
|
||||
results["n"] = n
|
||||
|
||||
# name: обрезаем до 100 символов
|
||||
raw_name = event.get("name", "")
|
||||
if not isinstance(raw_name, str):
|
||||
raw_name = str(raw_name)
|
||||
errors.append("name was not a string, converted")
|
||||
name = raw_name[:100]
|
||||
results["name"] = name
|
||||
|
||||
# flag: любое "truthy" значение
|
||||
raw_flag = event.get("flag", False)
|
||||
flag = raw_flag in (True, "true", "1", 1, "yes")
|
||||
results["flag"] = flag
|
||||
|
||||
return {"ok": len(errors) == 0, "errors": errors, "results": results}
|
||||
@@ -1,27 +0,0 @@
|
||||
# 2026-03-21 — chaos-bigpayload: генерирует/принимает большой JSON.
|
||||
# Тестирует: большие ответы (64KB+), память рантайма.
|
||||
import json, time
|
||||
|
||||
def bigpayload(event):
|
||||
size_kb = min(int(event.get("size_kb", 16)), 256) # cap 256KB
|
||||
word = str(event.get("word", "x"))[:32]
|
||||
|
||||
# Генерируем список строк нужного размера
|
||||
chunk = word * 32 # ~32+ байт на запись
|
||||
items = []
|
||||
total = 0
|
||||
target = size_kb * 1024
|
||||
i = 0
|
||||
while total < target:
|
||||
entry = f"{chunk}-{i}"
|
||||
items.append(entry)
|
||||
total += len(entry) + 3 # 3 байта JSON overhead
|
||||
i += 1
|
||||
|
||||
return {
|
||||
"items_count": len(items),
|
||||
"size_kb_approx": round(total / 1024, 1),
|
||||
"first": items[0] if items else "",
|
||||
"last": items[-1] if items else "",
|
||||
"ts": int(time.time()),
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
# 2026-03-21 — chaos-echo: отражает входные данные обратно.
|
||||
# Тестирует: большие payload, unicode, null, вложенные структуры, спецсимволы.
|
||||
# "Тупой юзер" шлёт всё что угодно — функция должна вернуть это обратно без падения.
|
||||
import json
|
||||
|
||||
def echo(event):
|
||||
# Пытаемся сериализовать обратно — выловит непериализуемые типы
|
||||
try:
|
||||
size = len(json.dumps(event))
|
||||
except Exception:
|
||||
size = -1
|
||||
|
||||
keys = list(event.keys()) if isinstance(event, dict) else []
|
||||
return {
|
||||
"echo": event,
|
||||
"keys": keys,
|
||||
"size_bytes": size,
|
||||
"type": type(event).__name__,
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
# 2026-03-21 — chaos-slowquery: намеренно медленный запрос через pg_sleep.
|
||||
# Тестирует: timeout enforcement — платформа должна прервать запрос если > timeout_sec.
|
||||
# sleep_sec cap = 8 (меньше timeout_sec=10 сервиса → успех; >10 → таймаут платформы).
|
||||
import os, psycopg2
|
||||
|
||||
def slowquery(event):
|
||||
sleep_sec = min(float(event.get("sleep_sec", 2.0)), 8.0)
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT pg_sleep(%s), now()::text", (sleep_sec,))
|
||||
result = cur.fetchone()
|
||||
return {"slept_sec": sleep_sec, "pg_now": result[1]}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,94 +0,0 @@
|
||||
# 2026-03-18 (обновлено: plain text вывод; фильтрация SLESS_EXCLUDE)
|
||||
# funcs_list.py — HTTP-функция: список пользовательских функций, человекочитаемый plain text.
|
||||
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
|
||||
# Возвращает str → python runtime отдаёт text/plain напрямую без json.dumps.
|
||||
#
|
||||
# Env vars:
|
||||
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
|
||||
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
|
||||
# SLESS_TOKEN — JWT токен для /v1/ API
|
||||
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru)
|
||||
# SLESS_EXCLUDE — comma-separated имена функций, которые не показывать
|
||||
|
||||
import os
|
||||
import requests
|
||||
|
||||
SEP = "─" * 52
|
||||
|
||||
|
||||
def _comment(fn, http_trigs, cron_trigs):
|
||||
phase = fn.get("phase", "?")
|
||||
runtime = fn.get("runtime", "?")
|
||||
if http_trigs:
|
||||
active = "активна" if http_trigs[0].get("active") else "неактивна"
|
||||
return f"HTTP endpoint ({runtime}) — {phase}, {active}"
|
||||
elif cron_trigs:
|
||||
schedule = cron_trigs[0].get("schedule", "?")
|
||||
active = "активна" if cron_trigs[0].get("active") else "неактивна"
|
||||
return f"Cron '{schedule}' ({runtime}) — {phase}, {active}"
|
||||
else:
|
||||
return f"Job/runner без триггера ({runtime}) — {phase}"
|
||||
|
||||
|
||||
def list_all(event):
|
||||
api_url = os.environ["SLESS_API_URL"].rstrip("/")
|
||||
namespace = os.environ["SLESS_NAMESPACE"]
|
||||
token = os.environ["SLESS_TOKEN"]
|
||||
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
|
||||
exclude = {n.strip() for n in os.environ.get("SLESS_EXCLUDE", "").split(",") if n.strip()}
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
fns = requests.get(f"{api_url}/v1/namespaces/{namespace}/functions", headers=headers, timeout=10)
|
||||
trs = requests.get(f"{api_url}/v1/namespaces/{namespace}/triggers", headers=headers, timeout=10)
|
||||
fns.raise_for_status()
|
||||
trs.raise_for_status()
|
||||
|
||||
trig_idx = {}
|
||||
for tr in trs.json():
|
||||
fn_name = tr.get("function") or tr.get("functionRef")
|
||||
if fn_name:
|
||||
trig_idx.setdefault(fn_name, []).append(tr)
|
||||
|
||||
items = []
|
||||
for fn in fns.json():
|
||||
name = fn["name"]
|
||||
if name in exclude:
|
||||
continue
|
||||
http_t = [t for t in trig_idx.get(name, []) if t.get("type") == "http"]
|
||||
cron_t = [t for t in trig_idx.get(name, []) if t.get("type") == "cron"]
|
||||
is_active = any(t.get("enabled", True) and t.get("active", False) for t in trig_idx.get(name, []))
|
||||
items.append((fn, http_t, cron_t, is_active))
|
||||
|
||||
# Сортировка: активные вверх, затем по имени
|
||||
items.sort(key=lambda x: (not x[3], x[0]["name"]))
|
||||
|
||||
lines = []
|
||||
for fn, http_t, cron_t, is_active in items:
|
||||
name = fn["name"]
|
||||
lines.append(SEP)
|
||||
lines.append(f" {_comment(fn, http_t, cron_t)}")
|
||||
lines.append(f" name: {name}")
|
||||
lines.append(f" runtime: {fn.get('runtime', '?')}")
|
||||
lines.append(f" phase: {fn.get('phase', '?')}")
|
||||
lines.append(f" active: {'да' if is_active else 'нет'}")
|
||||
|
||||
if http_t:
|
||||
url = f"{ext_url}/fn/{namespace}/{name}" if ext_url else http_t[0].get("url", "")
|
||||
lines.append(f" url: {url}")
|
||||
if cron_t:
|
||||
lines.append(f" cron: {cron_t[0].get('schedule', '?')}")
|
||||
if fn.get("created_at"):
|
||||
lines.append(f" created: {fn['created_at']}")
|
||||
if fn.get("last_built_at"):
|
||||
lines.append(f" built: {fn['last_built_at']}")
|
||||
if fn.get("message"):
|
||||
lines.append(f" message: {fn['message']}")
|
||||
|
||||
lines.append(SEP)
|
||||
lines.append(f" namespace: {namespace} | total: {len(items)}")
|
||||
lines.append(SEP)
|
||||
|
||||
# Возвращаем str — python runtime отдаст text/plain напрямую
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
requests==2.31.0
|
||||
@@ -1,43 +0,0 @@
|
||||
// 2026-03-21 — js-pg-batch: вставляет N строк через parameterized bulk query.
|
||||
// Тестирует: async/await PG с пакетной вставкой, Node.js под нагрузкой.
|
||||
const { Client } = require('pg');
|
||||
|
||||
async function run(event) {
|
||||
const n = Math.min(parseInt(event.n ?? 20, 10) || 20, 200);
|
||||
const prefix = String(event.prefix ?? 'js-batch').slice(0, 40);
|
||||
|
||||
const client = new Client({
|
||||
host: process.env.PGHOST,
|
||||
port: parseInt(process.env.PGPORT ?? '5432'),
|
||||
database: process.env.PGDATABASE,
|
||||
user: process.env.PGUSER,
|
||||
password: process.env.PGPASSWORD,
|
||||
ssl: { rejectUnauthorized: false },
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
const ts = Date.now();
|
||||
// Строим multi-value INSERT: INSERT INTO ... VALUES ($1), ($2), ...
|
||||
const placeholders = [];
|
||||
const values = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
placeholders.push(`($${i + 1})`);
|
||||
values.push(`${prefix}-${ts}-${i}`);
|
||||
}
|
||||
const sql = `INSERT INTO terraform_demo_table (title) VALUES ${placeholders.join(',')} RETURNING id`;
|
||||
const t0 = Date.now();
|
||||
const res = await client.query(sql, values);
|
||||
const elapsed = (Date.now() - t0) / 1000;
|
||||
|
||||
return {
|
||||
inserted: res.rowCount,
|
||||
first_id: res.rows[0]?.id ?? null,
|
||||
elapsed_sec: elapsed,
|
||||
};
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "js-pg-batch",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"pg": "^8.11.3"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
# 2026-03-21 — pg-bulk-insert: bulk INSERT через execute_values.
|
||||
# Тестирует: большие батчи (до 500 строк), производительность, память.
|
||||
import os, time, psycopg2, psycopg2.extras
|
||||
|
||||
def bulk_insert(event):
|
||||
try:
|
||||
n = max(0, min(int(event.get("n", 50)), 500)) # cap 500, min 0
|
||||
except (TypeError, ValueError):
|
||||
n = 50
|
||||
prefix = str(event.get("prefix", "bulk"))[:50]
|
||||
ts = int(time.time() * 1000)
|
||||
|
||||
# n=0 — граничный случай: вернуть сразу без обращения к PG.
|
||||
if n == 0:
|
||||
return {"inserted": 0, "first_id": None, "elapsed_sec": 0.0}
|
||||
|
||||
rows = [(f"{prefix}-{ts}-{i}",) for i in range(n)]
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
t0 = time.time()
|
||||
with conn.cursor() as cur:
|
||||
psycopg2.extras.execute_values(
|
||||
cur,
|
||||
"INSERT INTO terraform_demo_table (title) VALUES %s RETURNING id",
|
||||
rows,
|
||||
page_size=100,
|
||||
)
|
||||
ids = [r[0] for r in cur.fetchall()]
|
||||
conn.commit()
|
||||
elapsed = round(time.time() - t0, 3)
|
||||
return {"inserted": len(ids), "first_id": ids[0] if ids else None, "elapsed_sec": elapsed}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,38 +0,0 @@
|
||||
# 2026-03-21 — pg-dedup: удаляет дубликаты по title, оставляет первый (min id).
|
||||
# Тестирует: DELETE с subquery, CTE, idempotency (повторный вызов безопасен).
|
||||
import os, psycopg2
|
||||
|
||||
def dedup(event):
|
||||
dry_run = str(event.get("dry_run", "false")).lower() in ("true", "1", "yes")
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# Считаем сколько дублей есть
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM terraform_demo_table t1
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM terraform_demo_table t2
|
||||
WHERE t2.title = t1.title AND t2.id < t1.id
|
||||
)
|
||||
""")
|
||||
dupes_count = cur.fetchone()[0]
|
||||
|
||||
if not dry_run and dupes_count > 0:
|
||||
cur.execute("""
|
||||
DELETE FROM terraform_demo_table
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM terraform_demo_table GROUP BY title
|
||||
)
|
||||
""")
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
else:
|
||||
deleted = 0
|
||||
|
||||
return {"duplicates_found": dupes_count, "deleted": deleted, "dry_run": dry_run}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,33 +0,0 @@
|
||||
# 2026-03-21 — pg-delete-old: удаляет строки старше N минут (default 60).
|
||||
# Тестирует: DELETE с RETURNING, идемпотентность (повторный вызов = 0 удалений если нет старых).
|
||||
import os, psycopg2, psycopg2.extras
|
||||
|
||||
def delete_old(event):
|
||||
older_than_min = max(int(event.get("older_than_min", 60)), 1)
|
||||
prefix_filter = event.get("prefix", "")
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
if prefix_filter:
|
||||
cur.execute(
|
||||
"DELETE FROM terraform_demo_table "
|
||||
"WHERE created_at < now() - interval '1 minute' * %s "
|
||||
"AND title LIKE %s RETURNING id, title",
|
||||
(older_than_min, f"{prefix_filter}%"),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"DELETE FROM terraform_demo_table "
|
||||
"WHERE created_at < now() - interval '1 minute' * %s RETURNING id, title",
|
||||
(older_than_min,),
|
||||
)
|
||||
deleted = [dict(r) for r in cur.fetchall()]
|
||||
conn.commit()
|
||||
return {"deleted": len(deleted), "older_than_min": older_than_min, "sample": deleted[:5]}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,36 +0,0 @@
|
||||
# 2026-03-21 — pg-search: полнотекстовый поиск по title через ILIKE + LIMIT/OFFSET.
|
||||
# Тестирует: пагинацию, спецсимволы в input (XSS, SQL injection attempt → безопасно через параметры).
|
||||
import os, psycopg2, psycopg2.extras
|
||||
|
||||
def search(event):
|
||||
# «query» — основной параметр (user-friendly), «q» — алиас для совместимости.
|
||||
query = str(event.get("query") or event.get("q") or "")[:200]
|
||||
# int() может упасть если юзер прислал строку — защищаем try/except.
|
||||
try:
|
||||
limit = max(1, min(int(event.get("limit", 20)), 100))
|
||||
except (TypeError, ValueError):
|
||||
limit = 20
|
||||
try:
|
||||
offset = max(0, int(event.get("offset", 0)))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
pattern = f"%{query}%" if query else "%"
|
||||
cur.execute(
|
||||
"SELECT id, title, created_at::text FROM terraform_demo_table "
|
||||
"WHERE title ILIKE %s ORDER BY id DESC LIMIT %s OFFSET %s",
|
||||
(pattern, limit, offset),
|
||||
)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
cur.execute("SELECT COUNT(*) FROM terraform_demo_table WHERE title ILIKE %s", (pattern,))
|
||||
total = cur.fetchone()["count"]
|
||||
return {"rows": rows, "count": len(rows), "total": total, "q": query, "limit": limit, "offset": offset}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,36 +0,0 @@
|
||||
# 2026-03-21 — pg-upsert: INSERT ... ON CONFLICT (title) DO UPDATE.
|
||||
# Тестирует: идемпотентность вставки — один и тот же title можно вызывать 100 раз подряд.
|
||||
# Требует уникального индекса на title — создаётся при первом вызове (CREATE UNIQUE INDEX IF NOT EXISTS).
|
||||
import os, psycopg2
|
||||
|
||||
def upsert(event):
|
||||
title = str(event.get("title", "upsert-default"))[:255]
|
||||
payload = str(event.get("payload", ""))[:500]
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# Создаём уникальный индекс если нет — для поддержки ON CONFLICT
|
||||
cur.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS terraform_demo_table_title_uniq "
|
||||
"ON terraform_demo_table (title)"
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO terraform_demo_table (title) VALUES (%s) "
|
||||
"ON CONFLICT (title) DO UPDATE SET created_at = now() "
|
||||
"RETURNING id, title, created_at::text, xmax",
|
||||
(title,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
was_insert = row[3] == 0 # xmax=0 означает INSERT, иначе UPDATE
|
||||
conn.commit()
|
||||
return {
|
||||
"id": row[0], "title": row[1], "created_at": row[2],
|
||||
"action": "inserted" if was_insert else "updated",
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,54 +0,0 @@
|
||||
# 2026-03-21 — py-retry-writer: пишет N строк с retry при PG ошибке.
|
||||
# Тестирует: устойчивость к transient PG errors (simulate_error=true), retry logic,
|
||||
# корректный rollback при частичном сбое.
|
||||
import os, time, psycopg2, random
|
||||
|
||||
_MAX_RETRIES = 3
|
||||
|
||||
def retry_write(event):
|
||||
n = min(int(event.get("n", 5)), 100)
|
||||
prefix = str(event.get("prefix", "retry"))[:40]
|
||||
# simulate_error: с вероятностью 30% кидает OperationalError на 2-й попытке
|
||||
simulate = str(event.get("simulate_error", "false")).lower() in ("true", "1")
|
||||
|
||||
attempt = 0
|
||||
last_err = None
|
||||
|
||||
while attempt < _MAX_RETRIES:
|
||||
attempt += 1
|
||||
try:
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"], port=int(os.environ.get("PGPORT", 5432)),
|
||||
dbname=os.environ["PGDATABASE"], user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"], sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
inserted = []
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for i in range(n):
|
||||
# Симуляция: на первой попытке падаем с вероятностью 50%
|
||||
if simulate and attempt == 1 and i == n // 2:
|
||||
raise psycopg2.OperationalError("simulated transient error")
|
||||
title = f"{prefix}-{int(time.time()*1000)}-{i}-a{attempt}"
|
||||
cur.execute(
|
||||
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id",
|
||||
(title,),
|
||||
)
|
||||
inserted.append(cur.fetchone()[0])
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True, "inserted": len(inserted),
|
||||
"attempts": attempt, "first_id": inserted[0] if inserted else None,
|
||||
}
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
except psycopg2.OperationalError as e:
|
||||
last_err = str(e)
|
||||
if attempt < _MAX_RETRIES:
|
||||
time.sleep(0.3 * attempt) # exponential backoff
|
||||
continue
|
||||
|
||||
return {"ok": False, "attempts": attempt, "last_error": last_err}
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary
|
||||
@@ -1,3 +0,0 @@
|
||||
# 2026-03-17 00:00
|
||||
# requirements.txt — зависимости для функции запуска SQL.
|
||||
psycopg2-binary==2.9.9
|
||||
@@ -1,39 +0,0 @@
|
||||
# 2026-03-17 00:00
|
||||
# sql_runner.py — функция для выполнения SQL-операторов из входного события.
|
||||
import os
|
||||
import psycopg2
|
||||
|
||||
|
||||
def run_sql(event):
|
||||
# Выполняет список SQL-операторов в одной транзакции для атомарной инициализации схемы.
|
||||
# Параметры подключения передаются раздельно, чтобы избежать ошибок парсинга DSN при спецсимволах.
|
||||
pg_host = os.environ["PGHOST"]
|
||||
pg_port = os.environ.get("PGPORT", "5432")
|
||||
pg_database = os.environ["PGDATABASE"]
|
||||
pg_user = os.environ["PGUSER"]
|
||||
pg_password = os.environ["PGPASSWORD"]
|
||||
pg_sslmode = os.environ.get("PGSSLMODE", "require")
|
||||
statements = event.get("statements", [])
|
||||
|
||||
if not statements:
|
||||
return {"error": "no statements provided"}
|
||||
|
||||
connection = psycopg2.connect(
|
||||
host=pg_host,
|
||||
port=pg_port,
|
||||
dbname=pg_database,
|
||||
user=pg_user,
|
||||
password=pg_password,
|
||||
sslmode=pg_sslmode,
|
||||
)
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
for statement in statements:
|
||||
cursor.execute(statement)
|
||||
connection.commit()
|
||||
return {"ok": True, "executed": len(statements)}
|
||||
except Exception as error:
|
||||
connection.rollback()
|
||||
return {"error": str(error)}
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -1,20 +0,0 @@
|
||||
# 2026-03-19
|
||||
# stress_bigloop.py — CPU-интенсивная функция: считает сумму квадратов N чисел.
|
||||
# Проверяет поведение под нагрузкой (большая и средняя итерация).
|
||||
|
||||
import time
|
||||
|
||||
_VERSION = "v1"
|
||||
|
||||
|
||||
def run(event):
|
||||
n = int(event.get("n", 500_000))
|
||||
start = time.monotonic()
|
||||
total = sum(i * i for i in range(n))
|
||||
elapsed = round(time.monotonic() - start, 4)
|
||||
return {
|
||||
"version": _VERSION,
|
||||
"n": n,
|
||||
"sum_of_squares": total,
|
||||
"elapsed_sec": elapsed,
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
# 2026-03-19
|
||||
# stress_divzero.py — намеренно делит на ноль (ZeroDivisionError).
|
||||
# Проверяет: платформа перехватывает панику, возвращает HTTP 500, не роняет под.
|
||||
|
||||
_VERSION = "v1"
|
||||
|
||||
|
||||
def run(event):
|
||||
numerator = int(event.get("n", 42))
|
||||
denominator = int(event.get("d", 0)) # по умолчанию 0 — намеренный краш
|
||||
# ZeroDivisionError: проверяем что платформа обрабатывает исключения
|
||||
result = numerator / denominator
|
||||
return {"version": _VERSION, "result": result}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "stress-js-async",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"pg": "^8.11.0"
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// 2026-03-19
|
||||
// stress_js_async.js — делает 3 параллельных запроса к PG через Promise.all.
|
||||
// Проверяет nodejs20 runtime под умеренной нагрузкой и async/await.
|
||||
//
|
||||
// Entrypoint: stress_js_async.run
|
||||
|
||||
'use strict';
|
||||
|
||||
const { Client } = require('pg');
|
||||
|
||||
exports.run = async (event) => {
|
||||
const client = new Client({
|
||||
host: process.env.PGHOST,
|
||||
port: parseInt(process.env.PGPORT || '5432'),
|
||||
database: process.env.PGDATABASE,
|
||||
user: process.env.PGUSER,
|
||||
password: process.env.PGPASSWORD,
|
||||
ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : false,
|
||||
});
|
||||
await client.connect();
|
||||
try {
|
||||
const [ver, cnt, max] = await Promise.all([
|
||||
client.query('SELECT version() AS v'),
|
||||
client.query('SELECT COUNT(*) AS cnt FROM terraform_demo_table'),
|
||||
client.query('SELECT MAX(id) AS max_id FROM terraform_demo_table'),
|
||||
]);
|
||||
return {
|
||||
runtime: 'nodejs20',
|
||||
version: 'v1',
|
||||
pg_version: ver.rows[0].v.split(' ').slice(0, 2).join(' '),
|
||||
total_rows: parseInt(cnt.rows[0].cnt, 10),
|
||||
max_id: max.rows[0].max_id,
|
||||
};
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "stress-js-badenv",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// 2026-03-19
|
||||
// stress_js_badenv.js — читает несуществующую переменную env и падает.
|
||||
// Проверяет: платформа перехватывает TypeError/undefined, возвращает 500.
|
||||
//
|
||||
// Entrypoint: stress_js_badenv.run
|
||||
|
||||
'use strict';
|
||||
|
||||
exports.run = async (event) => {
|
||||
const crash = event.crash !== false; // по умолчанию crash=true
|
||||
if (crash) {
|
||||
// Читаем несуществующий env, пытаемся вызвать .toUpperCase() на undefined
|
||||
const val = process.env.THIS_VAR_DOES_NOT_EXIST_AT_ALL;
|
||||
return { shout: val.toUpperCase() }; // TypeError: Cannot read properties of undefined
|
||||
}
|
||||
return { runtime: 'nodejs20', version: 'v1', crashed: false };
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
# 2026-03-19
|
||||
# stress_slow.py — долгая функция: спит N секунд (по умолчанию 8).
|
||||
# Проверяет что timeout-механизм и параллельные запросы не блокируют друг друга.
|
||||
|
||||
import time
|
||||
import os
|
||||
|
||||
_VERSION = "v1"
|
||||
|
||||
|
||||
def run(event):
|
||||
secs = int(event.get("sleep", 8))
|
||||
time.sleep(secs)
|
||||
return {
|
||||
"version": _VERSION,
|
||||
"slept_sec": secs,
|
||||
"pid": os.getpid(),
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary==2.9.9
|
||||
@@ -1,39 +0,0 @@
|
||||
# 2026-03-19
|
||||
# stress_writer.py — пишет N строк в terraform_demo_table (по умолчанию 5).
|
||||
# Проверяет параллельные INSERT'ы и устойчивость соединения с PG при нагрузке.
|
||||
|
||||
import os
|
||||
import psycopg2
|
||||
import time
|
||||
|
||||
_VERSION = "v1"
|
||||
|
||||
|
||||
def run(event):
|
||||
n = int(event.get("rows", 5))
|
||||
prefix = event.get("prefix", "stress")
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ["PGHOST"],
|
||||
port=int(os.environ.get("PGPORT", "5432")),
|
||||
dbname=os.environ["PGDATABASE"],
|
||||
user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"],
|
||||
sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
inserted = []
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for i in range(n):
|
||||
title = f"{prefix}-{int(time.time()*1000)}-{i}"
|
||||
cur.execute(
|
||||
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id",
|
||||
(title,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
inserted.append({"id": row[0], "title": title})
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"version": _VERSION, "inserted": inserted, "count": len(inserted)}
|
||||
@@ -1 +0,0 @@
|
||||
psycopg2-binary==2.9.9
|
||||
@@ -1,133 +0,0 @@
|
||||
# 2026-03-19 — добавлен version и hostname в ответ list_rows для тестирования обновления кода
|
||||
# table_rw.py — чтение и запись строк в terraform_demo_table.
|
||||
# Два entrypoint в одном файле: list_rows (JSON API) и add_row (HTML-страница + POST-обработчик).
|
||||
# ENV: PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE
|
||||
|
||||
import os
|
||||
import json
|
||||
import socket
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
_CODE_VERSION = "v2-with-hostname"
|
||||
|
||||
|
||||
def _connect():
|
||||
return psycopg2.connect(
|
||||
host=os.environ["PGHOST"],
|
||||
port=os.environ.get("PGPORT", "5432"),
|
||||
dbname=os.environ["PGDATABASE"],
|
||||
user=os.environ["PGUSER"],
|
||||
password=os.environ["PGPASSWORD"],
|
||||
sslmode=os.environ.get("PGSSLMODE", "require"),
|
||||
)
|
||||
|
||||
|
||||
def list_rows(event):
|
||||
# Возвращает все строки terraform_demo_table, отсортированные по убыванию created_at.
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC"
|
||||
)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
return {"rows": rows, "count": len(rows), "version": _CODE_VERSION, "host": socket.gethostname()}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _render_page(rows, message=""):
|
||||
# HTML-страница с формой ввода и таблицей строк.
|
||||
# message — статус последней операции (успех / ошибка).
|
||||
rows_html = "".join(
|
||||
f"<tr><td>{r['id']}</td><td>{r['title']}</td><td>{r['created_at']}</td></tr>"
|
||||
for r in rows
|
||||
)
|
||||
msg_html = f'<p class="msg">{message}</p>' if message else ""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>pg-table-writer</title>
|
||||
<style>
|
||||
body {{ font-family: sans-serif; max-width: 700px; margin: 40px auto; background: #111; color: #eee; }}
|
||||
h1 {{ color: #7dd3fc; }}
|
||||
form {{ display: flex; gap: 8px; margin-bottom: 24px; }}
|
||||
input[type=text] {{ flex: 1; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; background: #1e1e1e; color: #eee; font-size: 15px; }}
|
||||
button {{ padding: 8px 18px; background: #2563eb; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 15px; }}
|
||||
button:hover {{ background: #1d4ed8; }}
|
||||
table {{ width: 100%; border-collapse: collapse; }}
|
||||
th, td {{ padding: 8px 10px; border-bottom: 1px solid #333; text-align: left; }}
|
||||
th {{ color: #7dd3fc; }}
|
||||
.msg {{ color: #4ade80; margin-bottom: 12px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>pg-table-writer</h1>
|
||||
<form method="POST">
|
||||
<input type="text" name="title" placeholder="Введите строку..." autofocus required>
|
||||
<button type="submit">Добавить</button>
|
||||
</form>
|
||||
{msg_html}
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>title</th><th>created_at</th></tr></thead>
|
||||
<tbody>{rows_html}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def add_row(event):
|
||||
# GET → HTML-страница с формой и списком строк.
|
||||
# POST → вставляет строку из form-поля title или JSON-поля title,
|
||||
# затем возвращает обновлённую HTML-страницу.
|
||||
# POST с Content-Type: application/json (curl/API) → возвращает JSON.
|
||||
method = event.get("_method", "GET")
|
||||
|
||||
if method == "GET":
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
return _render_page(rows)
|
||||
|
||||
# POST — вставка строки
|
||||
# Поле title приходит либо из JSON-тела, либо из application/x-www-form-urlencoded.
|
||||
# Сервер уже распарсил JSON в event; form-данные приходят как event["body"] = "title=...".
|
||||
title = event.get("title", "").strip()
|
||||
if not title:
|
||||
# Попытка распарсить form-encoded body (браузерная форма)
|
||||
body = event.get("body", "")
|
||||
if body.startswith("title="):
|
||||
from urllib.parse import unquote_plus
|
||||
title = unquote_plus(body[len("title="):].split("&")[0]).strip()
|
||||
|
||||
if not title:
|
||||
return {"ok": False, "error": "title is required"}
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id, title, created_at::text",
|
||||
(title,),
|
||||
)
|
||||
row = dict(cur.fetchone())
|
||||
conn.commit()
|
||||
|
||||
# Если запрос из браузера (form POST) — возвращаем обновлённую страницу.
|
||||
# Если из curl/API — возвращаем JSON.
|
||||
accept = event.get("_accept", "")
|
||||
if "application/json" in accept:
|
||||
return {"ok": True, "row": row}
|
||||
|
||||
# Перечитываем все строки для обновлённой страницы
|
||||
cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
return _render_page(rows, message=f"Добавлено: «{row['title']}»")
|
||||
finally:
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user