Author SHA1 Message Date
Naeel bc0e00acca feat(vm): add vApp + VM terraform example 2026-03-25 08:17:08 +03:00
Naeel 3404af578b feat(builder): py_compile + node --check при сборке ловят синтаксические ошибки (v0.1.63) 2026-03-23 11:23:01 +03:00
Naeel 8051870208 fix(web-console): убраны память/таймаут, двойной рендер кода (v0.1.11) 2026-03-23 11:14:32 +03:00
Naeel cad89fdbb6 fix(web-console): NotFoundError — urlRow создаётся сразу, не через insertBefore (v0.1.10) 2026-03-23 11:05:30 +03:00
Naeel 6e73729c46 feat(web-console): URL строкой ниже, статус сборки после Save (v0.1.9) 2026-03-23 11:01:01 +03:00
Naeel 470039f6d6 fix(web-console): таймер исчезает после Building→Ready, шаблоны без context (v0.1.8) 2026-03-23 10:52:37 +03:00
Naeel f68b601484 fix(web-console): zip invalid — Close() после Bytes() (v0.1.6) 2026-03-23 10:47:25 +03:00
Naeel 442ba8bc2f feat(web-console): deps, rename, Building poll+timer, kind=service — v0.1.5 2026-03-23 10:41:43 +03:00
Naeel b7fa8acf76 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)
2026-03-23 10:19:42 +03:00
53 changed files with 1521 additions and 1329 deletions
-257
View File
@@ -1,257 +0,0 @@
// 2026-03-21 — chaos_marathon.tf: 15 новых сервисов для часового хаос-марафона.
// Два рантайма: python3.11 (9), nodejs20 (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]
}
# ── 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]
}
+116
View File
@@ -0,0 +1,116 @@
// Создано: 2026-04-10
// Простой калькулятор — возвращает HTML страницу с кнопками.
// GET → HTML; POST с {a, op, b} → вычисление с результатом на странице.
// Рантайм nodejs20 v0.1.3+ поддерживает HTML-ответ (строка начинающаяся с '<').
'use strict';
const HTML = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Калькулятор (Node.js)</title>
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #0f2027; }
.calc { background: #1a2a1a; border-radius: 16px; padding: 24px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); width: 280px; }
h2 { color: #d4f7d4; text-align: center; margin: 0 0 16px; font-size: 18px; }
.badge { text-align: center; font-size: 11px; color: #4a7a4a; margin-bottom: 16px; }
.display { background: #0a1a0a; color: #e0ffe0; font-size: 28px; text-align: right; padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; min-height: 52px; word-break: break-all; }
.result { color: #4ade80; font-size: 20px; text-align: right; padding: 4px 16px; margin-bottom: 8px; min-height: 28px; }
.btns { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
button { border: none; border-radius: 8px; padding: 16px 0; font-size: 18px; cursor: pointer; transition: filter 0.1s; }
button:active { filter: brightness(1.3); }
.btn-num { background: #1e3a1e; color: #d4f7d4; }
.btn-op { background: #166534; color: #fff; }
.btn-eq { background: #15803d; color: #fff; grid-column: span 2; }
.btn-clr { background: #7f1d1d; color: #fca5a5; grid-column: span 2; }
.btn-zero { grid-column: span 2; }
</style>
</head>
<body>
<div class="calc">
<h2>Калькулятор</h2>
<div class="badge">Node.js 20</div>
<div id="display" class="display">0</div>
<div id="result" class="result">RESULT_LINE</div>
<div class="btns">
<button class="btn-clr btn-zero" onclick="clr()">C</button>
<button class="btn-op" onclick="setOp('%2F')">÷</button>
<button class="btn-op" onclick="setOp('*')">×</button>
<button class="btn-num" onclick="inp('7')">7</button>
<button class="btn-num" onclick="inp('8')">8</button>
<button class="btn-num" onclick="inp('9')">9</button>
<button class="btn-op" onclick="setOp('-')"></button>
<button class="btn-num" onclick="inp('4')">4</button>
<button class="btn-num" onclick="inp('5')">5</button>
<button class="btn-num" onclick="inp('6')">6</button>
<button class="btn-op" onclick="setOp('+')">+</button>
<button class="btn-num" onclick="inp('1')">1</button>
<button class="btn-num" onclick="inp('2')">2</button>
<button class="btn-num" onclick="inp('3')">3</button>
<button class="btn-num btn-zero" onclick="inp('0')">0</button>
<button class="btn-num" onclick="inp('.')">.</button>
<button class="btn-eq" onclick="calc()">=</button>
</div>
</div>
<script>
let a = '', op = '', b = '';
const disp = document.getElementById('display');
function inp(v) {
if (op === '') { a += v; disp.textContent = a || '0'; }
else { b += v; disp.textContent = b || '0'; }
}
function setOp(o) {
if (a === '') return;
if (b !== '') calc();
else { op = o; disp.textContent = a + ' ' + decodeURIComponent(o) + ' …'; }
}
function clr() { a = ''; op = ''; b = ''; disp.textContent = '0'; }
function calc() {
if (a === '' || op === '' || b === '') return;
const form = document.createElement('form');
form.method = 'POST'; form.action = '';
const fields = {a, op, b};
for (const [k, v] of Object.entries(fields)) {
const i = document.createElement('input');
i.type = 'hidden'; i.name = k; i.value = decodeURIComponent(v);
form.appendChild(i);
}
document.body.appendChild(form);
form.submit();
}
</script>
</body>
</html>`;
// Форматирует число — убирает лишний .0 для целых
function fmt(n) { return n === Math.trunc(n) ? String(Math.trunc(n)) : String(n); }
function buildPage(resultLine) {
return HTML.replace('RESULT_LINE', resultLine || '');
}
function compute(event) {
// POST: тело приходит как JSON; поля a, op, b — строки из form
const a = parseFloat(event.a);
const b = parseFloat(event.b);
const op = event.op;
if (isNaN(a) || isNaN(b)) return buildPage('Некорректное число');
let res;
switch (op) {
case '+': res = a + b; break;
case '-': res = a - b; break;
case '*': res = a * b; break;
case '/':
if (b === 0) return buildPage('Деление на ноль');
res = a / b; break;
default: return buildPage('Неизвестный оператор');
}
return buildPage(`${fmt(a)} ${op} ${fmt(b)} = ${fmt(res)}`);
}
module.exports.handler = async function handler(event) {
if (event._method === 'POST') return compute(event);
return buildPage('');
};
@@ -0,0 +1,3 @@
{
"dependencies": {}
}
@@ -0,0 +1,120 @@
# Создано: 2026-04-10
# Простой калькулятор — возвращает HTML страницу с кнопками.
# Роутинг: GET → HTML страница; POST с {a, op, b} → вычисление и редирект с результатом.
# _method приходит от python3.11 рантайма в event.
_HTML = """<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Калькулятор (Python)</title>
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #1a1a2e; }
.calc { background: #16213e; border-radius: 16px; padding: 24px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); width: 280px; }
h2 { color: #e2e8f0; text-align: center; margin: 0 0 16px; font-size: 18px; }
.badge { text-align: center; font-size: 11px; color: #64748b; margin-bottom: 16px; }
.display { background: #0f172a; color: #f1f5f9; font-size: 28px; text-align: right; padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; min-height: 52px; word-break: break-all; }
.result { color: #22d3ee; font-size: 20px; text-align: right; padding: 4px 16px; margin-bottom: 8px; min-height: 28px; }
.btns { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
button { border: none; border-radius: 8px; padding: 16px 0; font-size: 18px; cursor: pointer; transition: filter 0.1s; }
button:active { filter: brightness(1.3); }
.btn-num { background: #334155; color: #f1f5f9; }
.btn-op { background: #0891b2; color: #fff; }
.btn-eq { background: #0d9488; color: #fff; grid-column: span 2; }
.btn-clr { background: #7f1d1d; color: #fca5a5; grid-column: span 2; }
.btn-zero { grid-column: span 2; }
</style>
</head>
<body>
<div class="calc">
<h2>Калькулятор</h2>
<div class="badge">Python 3.11</div>
<div id="display" class="display">0</div>
<div id="result" class="result">{result_line}</div>
<div class="btns">
<button class="btn-clr btn-zero" onclick="clr()">C</button>
<button class="btn-op" onclick="setOp('%2F')">÷</button>
<button class="btn-op" onclick="setOp('*')">×</button>
<button class="btn-num" onclick="inp('7')">7</button>
<button class="btn-num" onclick="inp('8')">8</button>
<button class="btn-num" onclick="inp('9')">9</button>
<button class="btn-op" onclick="setOp('-')"></button>
<button class="btn-num" onclick="inp('4')">4</button>
<button class="btn-num" onclick="inp('5')">5</button>
<button class="btn-num" onclick="inp('6')">6</button>
<button class="btn-op" onclick="setOp('+')">+</button>
<button class="btn-num" onclick="inp('1')">1</button>
<button class="btn-num" onclick="inp('2')">2</button>
<button class="btn-num" onclick="inp('3')">3</button>
<button class="btn-num btn-zero" onclick="inp('0')">0</button>
<button class="btn-num" onclick="inp('.')">.</button>
<button class="btn-eq" onclick="calc()">=</button>
</div>
</div>
<script>
let a = '', op = '', b = '', fresh = {fresh_js};
const disp = document.getElementById('display');
function inp(v) {{
if (op === '') {{ a += v; disp.textContent = a || '0'; }}
else {{ b += v; disp.textContent = b || '0'; }}
fresh = false;
}}
function setOp(o) {{
if (a === '') return;
if (b !== '') calc();
else {{ op = o; disp.textContent = a + ' ' + decodeURIComponent(o) + ''; }}
fresh = false;
}}
function clr() {{ a = ''; op = ''; b = ''; disp.textContent = '0'; }}
function calc() {{
if (a === '' || op === '' || b === '') return;
const form = document.createElement('form');
form.method = 'POST'; form.action = '';
const fields = {{a, op, b}};
for (const [k, v] of Object.entries(fields)) {{
const i = document.createElement('input');
i.type = 'hidden'; i.name = k; i.value = decodeURIComponent(v);
form.appendChild(i);
}}
document.body.appendChild(form);
form.submit();
}}
</script>
</body>
</html>"""
def handler(event):
# POST: тело приходит как JSON от рантайма
if event.get('_method') == 'POST':
return _compute(event)
# GET: показываем чистую страницу
return _html()
def _html(result_line='', fresh_js='true'):
return _HTML.replace('{result_line}', result_line).replace('{fresh_js}', fresh_js)
def _compute(event):
try:
a = float(event.get('a', 0))
b = float(event.get('b', 0))
op = event.get('op', '')
if op == '+':
res = a + b
elif op == '-':
res = a - b
elif op == '*':
res = a * b
elif op == '/':
if b == 0:
return _html('Деление на ноль', 'false')
res = a / b
else:
return _html('Неизвестный оператор', 'false')
# Убираем лишний .0 для целых результатов
res_str = str(int(res)) if res == int(res) else str(res)
return _html(f'{int(a) if a == int(a) else a} {op} {int(b) if b == int(b) else b} = {res_str}', 'false')
except (ValueError, TypeError) as exc:
return _html(f'Ошибка: {exc}', 'false')
@@ -0,0 +1 @@
# нет внешних зависимостей
@@ -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
-133
View File
@@ -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()
+22 -95
View File
@@ -1,105 +1,32 @@
// 2026-03-20 (merge: sless_function + старый sless_job объединены в один self-contained sless_job)
// Теперь sless_job несёт в себе runtime/entrypoint/source_dir — не нужен отдельный sless_function.
// WaitJobDone таймаут 900s покрывает kaniko сборку (~5 мин) + выполнение SQL (~несколько сек).
# Создано: 2026-04-10
# functions.tf — sless_service ресурсы для примера POSTGRES.
# Здесь: два калькуляторa — Python и Node.js.
# sless_service = long-running Deployment + постоянный URL (в отличие от sless_function).
# Одноразовый запуск: собирает образ через kaniko, выполняет SQL, завершается.
# Заменяет sless_function.postgres_sql_runner_create_table + sless_job.postgres_table_init_job.
resource "sless_job" "postgres_table_init_job" {
name = "pg-create-table-job-main-v13"
runtime = "python3.11"
entrypoint = "sql_runner.run_sql"
memory_mb = 128
timeout_sec = 30
source_dir = "${path.module}/code/sql-runner"
wait_timeout_sec = 900
run_id = 13
# ─── Python-калькулятор ──────────────────────────────────────────────────────
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
event_json = jsonencode({
statements = [
"CREATE TABLE IF NOT EXISTS terraform_demo_table (id serial PRIMARY KEY, title text NOT NULL, created_at timestamp DEFAULT now())"
]
})
depends_on = [nubes_postgres_database.db]
resource "sless_service" "calc_python" {
name = "calc-python"
runtime = "python3.11"
entrypoint = "handler.handler"
source_dir = "${path.module}/code/calc-python"
}
# Long-running сервис на NodeJS: возвращает версию PG-сервера и счётчик строк в таблице.
resource "sless_service" "pg_info" {
name = "pg-info"
runtime = "nodejs20"
entrypoint = "pg_info.info"
memory_mb = 128
timeout_sec = 15
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/pg-info"
depends_on = [sless_job.postgres_table_init_job]
output "calc_python_url" {
description = "URL Python-калькулятора"
value = sless_service.calc_python.url
}
resource "sless_service" "postgres_table_reader" {
name = "pg-table-reader"
runtime = "python3.11"
entrypoint = "table_rw.list_rows"
memory_mb = 128
timeout_sec = 30
# ─── Node.js-калькулятор ─────────────────────────────────────────────────────
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/table-rw"
depends_on = [sless_job.postgres_table_init_job]
resource "sless_service" "calc_node" {
name = "calc-node"
runtime = "nodejs20"
entrypoint = "handler.handler"
source_dir = "${path.module}/code/calc-node"
}
output "table_reader_url" {
value = sless_service.postgres_table_reader.url
}
resource "sless_service" "postgres_table_writer" {
name = "pg-table-writer"
runtime = "python3.11"
entrypoint = "table_rw.add_row"
memory_mb = 256
timeout_sec = 45
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
source_dir = "${path.module}/code/table-rw"
depends_on = [sless_job.postgres_table_init_job]
}
output "table_writer_url" {
value = sless_service.postgres_table_writer.url
output "calc_node_url" {
description = "URL Node.js-калькулятора"
value = sless_service.calc_node.url
}
-119
View File
@@ -1,119 +0,0 @@
// 2026-03-21 — stress.tf: все стресс-сервисы для комплексного тестирования.
// Два рантайма: nodejs20 (2), python3.11 (5).
// Все depends_on = [sless_job.postgres_table_init_job] — таблица должна существовать.
# ── Node.js 20 ────────────────────────────────────────────────────────────────
# 3 параллельных PG-запроса через Promise.all. Проверяет async/await + nodejs20.
resource "sless_service" "stress_js_async" {
name = "stress-js-async"
runtime = "nodejs20"
entrypoint = "stress_js_async.run"
memory_mb = 128
timeout_sec = 20
source_dir = "${path.module}/code/stress-js-async"
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
depends_on = [sless_job.postgres_table_init_job]
}
# TypeError через undefined.toUpperCase(). Без PG. Проверяет перехват JS-ошибок.
resource "sless_service" "stress_js_badenv" {
name = "stress-js-badenv"
runtime = "nodejs20"
entrypoint = "stress_js_badenv.run"
memory_mb = 128
timeout_sec = 10
source_dir = "${path.module}/code/stress-js-badenv"
depends_on = [sless_job.postgres_table_init_job]
}
# ── Python 3.11 ───────────────────────────────────────────────────────────────
# Спит N секунд. Без PG. Проверяет timeout и сосуществование долгих запросов.
resource "sless_service" "stress_slow" {
name = "stress-slow"
runtime = "python3.11"
entrypoint = "stress_slow.run"
memory_mb = 128
timeout_sec = 35
source_dir = "${path.module}/code/stress-slow"
depends_on = [sless_job.postgres_table_init_job]
}
# CPU-нагрузка: сумма квадратов N чисел. Без PG. Проверяет compute-bound задачи.
resource "sless_service" "stress_bigloop" {
name = "stress-bigloop"
runtime = "python3.11"
entrypoint = "stress_bigloop.run"
memory_mb = 256
timeout_sec = 60
source_dir = "${path.module}/code/stress-bigloop"
depends_on = [sless_job.postgres_table_init_job]
}
# ZeroDivisionError. Без PG. Проверяет перехват Python-исключений → HTTP 500.
resource "sless_service" "stress_divzero" {
name = "stress-divzero"
runtime = "python3.11"
entrypoint = "stress_divzero.run"
memory_mb = 128
timeout_sec = 10
source_dir = "${path.module}/code/stress-divzero"
depends_on = [sless_job.postgres_table_init_job]
}
# Параллельный INSERT в terraform_demo_table через psycopg2. Проверяет PG-write.
resource "sless_service" "stress_writer" {
name = "stress-writer"
runtime = "python3.11"
entrypoint = "stress_writer.run"
memory_mb = 128
timeout_sec = 60
source_dir = "${path.module}/code/stress-writer"
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
depends_on = [sless_job.postgres_table_init_job]
}
# Агрегированная статистика terraform_demo_table (COUNT/MIN/MAX). Для мониторинга.
resource "sless_service" "pg_stats" {
name = "pg-stats"
runtime = "python3.11"
entrypoint = "pg_stats.get_stats"
memory_mb = 128
timeout_sec = 15
source_dir = "${path.module}/code/pg-stats"
env_vars = {
PGHOST = local.pg_host
PGPORT = "5432"
PGDATABASE = local.pg_database
PGUSER = local.pg_username
PGPASSWORD = local.pg_password
PGSSLMODE = "require"
}
depends_on = [sless_job.postgres_table_init_job]
}
+93
View File
@@ -0,0 +1,93 @@
# Пример: Виртуальная машина (vApp + VM) в Nubes vDC
Создаёт:
- **vApp** — виртуальный каталог (контейнер для ВМ в VMware vDC)
- **ВМ** — Ubuntu 22.04, 2 CPU / 2 GB RAM / 20 GB disk
---
## Что нужно сделать перед запуском
### 1. Сгенерировать SSH-ключ
Публичный ключ прописывается в ВМ при создании — это единственный способ зайти по SSH.
Приватный ключ нужен хранить у себя.
```bash
ssh-keygen -t ed25519 -f ~/.ssh/sless-demo-vm -N "" -C "sless-demo-vm"
```
Публичный ключ (`~/.ssh/sless-demo-vm.pub`) — строка вида:
```
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... sless-demo-vm
```
### 2. Заполнить terraform.tfvars
Открыть файл `terraform.tfvars` и заменить значения:
```hcl
# Ваш API-токен из панели Nubes
api_token = "ВСТАВИТЬ_ТОКЕН"
# Публичный ключ из шага 1
vm_public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA..."
```
> **Токен** — берётся в панели Nubes: профиль → API-токены.
> **Ключ** — содержимое файла `~/.ssh/sless-demo-vm.pub` (публичный, не приватный!).
---
## Запуск
```bash
cd examples/VM
terraform init
terraform apply
```
После `apply` в выводе будет:
```
Outputs:
vm_id = "..."
vm_state = {
"externalIp" = "1.2.3.4"
...
}
vapp_id = "..."
```
---
## Подключение по SSH
```bash
ssh -i ~/.ssh/sless-demo-vm ubuntu@<externalIp из outputs>
```
Логин — `ubuntu` (задан в `vm.tf`).
---
## Удаление
```bash
terraform destroy
```
Порядок автоматический: сначала suspend → потом delete. Без suspend удаление упадёт с ошибкой — это поведение Nubes, параметр `suspend_on_destroy = true` в ресурсах решает это.
---
## Что можно менять
| Параметр | Файл | Примечание |
|----------|------|-----------|
| `vm_cpu`, `vm_ram`, `vm_disk` | `vm.tf` | Можно менять и переприменять |
| `resource_name`, `vapp_name` | `vapp.tf` | **Не изменяется после создания** |
| `image_vm`, `user_login`, `user_public_key` | `vm.tf` | **Не изменяется после создания** |
| `vdc_uid`, `nsxt_uid` | `vapp.tf` | **Не изменяется после создания** |
+38
View File
@@ -0,0 +1,38 @@
// 2026-03-25 — main.tf для примера с vApp + ВМ (Виртуальный датацентр Nubes).
// Провайдер nubes. Sless-провайдер не нужен — пример чисто инфраструктурный.
terraform {
required_providers {
nubes = {
source = "terra.k8c.ru/nubes/nubes"
version = "5.0.31"
}
}
}
# ------------------------------------------------------------------
# Переменные
# ------------------------------------------------------------------
variable "vm_public_key" {
type = string
sensitive = true
description = "Публичный SSH-ключ для ВМ. Приватный ключ: ~/terra/sless/examples/VM/vm_key"
}
variable "api_token" {
type = string
sensitive = true
description = "Nubes API token"
}
# ------------------------------------------------------------------
# Провайдер
# ------------------------------------------------------------------
# API Dashboard (для Terraform-провайдеров): https://deck-api-test.ngcloud.ru/api/v1/index.cfm
# UI облака (только браузер): https://deck-test.ngcloud.ru/
provider "nubes" {
api_token = var.api_token
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
}
+28
View File
@@ -0,0 +1,28 @@
// 2026-03-25 — vapp.tf: виртуальный каталог ВМ (vApp) в Nubes vDC.
// nubes_vapp — контейнер для ВМ внутри Виртуального датацентра.
// Обязательные поля: vdc_uid, nsxt_uid, vapp_name, resource_name.
resource "nubes_vapp" "vapp" {
resource_name = "vm-sless-demo-vapp"
vapp_name = "vapp-sless-demo" # Уникальное в рамках организации. Не изменяется после создания.
vdc_uid = "e3c9e4f1-24da-4992-a003-f8a2a803a5f0" # UUID Услуги «Виртуальный датацентр (vDC)». Не изменяется после создания.
nsxt_uid = "0fe88e2a-31b6-4385-ad52-e27c6c0d38a6" # UUID Услуги «Сетевой шлюз периметра (Edge)». Не изменяется после создания.
adopt_existing_on_create = true
operation_timeout = "15m"
# ВАЖНО: delete без предварительного suspend завершается ошибкой
# "Невозможно выполнить операцию удаления услуги. Услуга не остановлена"
# suspend_on_destroy гарантирует правильный порядок: suspend → delete.
suspend_on_destroy = true
}
output "vapp_id" {
value = nubes_vapp.vapp.id
description = "ID созданного vApp (используется как vapp_uid при создании ВМ)"
}
output "vapp_state" {
value = nubes_vapp.vapp.state_out_flat
description = "Плоский state vApp — адреса, статусы сети и т.д."
}
+34
View File
@@ -0,0 +1,34 @@
// 2026-03-25 — vm.tf: виртуальная машина (nubes_vc_vm_v3) внутри vApp.
// Зависит от nubes_vapp.vapp — создаётся после vApp.
// image_vm, vapp_uid, user_public_key не изменяются после создания.
resource "nubes_vc_vm_v3" "vm" {
resource_name = "vm-sless-demo"
vapp_uid = nubes_vapp.vapp.id # ссылка на vApp. Не изменяется после создания.
image_vm = "Ubuntu 22.04 LTS" # Не изменяется после создания.
ip_space_name = "internet-ipv4-v1"
user_login = "ubuntu"
user_public_key = var.vm_public_key # задаётся в terraform.tfvars
vm_cpu = 2
vm_ram = 2 # GB
vm_disk = 20 # GB
adopt_existing_on_create = true
operation_timeout = "15m"
# delete без предварительного suspend завершается ошибкой (аналогично vApp).
suspend_on_destroy = true
}
output "vm_id" {
value = nubes_vc_vm_v3.vm.id
description = "ID созданной ВМ"
}
output "vm_state" {
value = nubes_vc_vm_v3.vm.state_out_flat
description = "Плоский state ВМ — IP-адреса, статус и т.д."
}
+6
View File
@@ -132,11 +132,17 @@ func generateDockerfile(runtime string, hasRequirements bool, hasPackageJSON boo
if hasRequirements {
content += "RUN pip install --no-cache-dir -r /app/function/requirements.txt\n"
}
// Проверяем синтаксис всех .py файлов на этапе сборки образа.
// Если в коде синтаксическая ошибка — kaniko завершится с ошибкой и фаза = Failed.
// Без этого шага образ собирается успешно, а под падает в CrashLoopBackOff при запуске.
content += "RUN python -m py_compile $(find /app/function -name '*.py')\n"
case "nodejs20":
if hasPackageJSON {
// cd нужен т.к. npm install читает package.json из текущей директории
content += "RUN cd /app/function && npm install --omit=dev\n"
}
// Проверяем синтаксис всех .js файлов через node --check.
content += "RUN find /app/function -name '*.js' | xargs -r node --check\n"
}
return []byte(content), nil
}
+17 -1
View File
@@ -1,4 +1,5 @@
// Изменено: 2026-03-09
// Изменено: 2026-04-10 (v0.1.3) — sendResult: если handler вернул строку начинающуюся с '<' → text/html.
// HTTP-обёртка для serverless функций на Node.js 20.
// Загружает модуль из SLESS_ENTRYPOINT или handler.js по умолчанию.
// Формат SLESS_ENTRYPOINT: "module-name.functionName" (например: handler-http.handle)
@@ -65,7 +66,7 @@ const server = http.createServer(async (req, res) => {
try {
const result = await userHandle(event);
sendJSON(res, 200, result);
sendResult(res, 200, result);
} catch (err) {
console.error('Handler error:', err);
sendJSON(res, 500, { error: err.message });
@@ -73,6 +74,21 @@ const server = http.createServer(async (req, res) => {
});
});
function sendResult(res, status, data) {
// Если handler вернул строку начинающуюся с '<' — HTML страница.
// Если строка, но не HTML — text/plain. Иначе — JSON.
if (typeof data === 'string') {
const ctype = data.trimStart().startsWith('<')
? 'text/html; charset=utf-8'
: 'text/plain; charset=utf-8';
const buf = Buffer.from(data, 'utf-8');
res.writeHead(status, { 'Content-Type': ctype, 'Content-Length': buf.length });
res.end(buf);
return;
}
sendJSON(res, status, data);
}
function sendJSON(res, status, data) {
const body = JSON.stringify(data);
res.writeHead(status, {
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
spec:
containers:
- name: funcs
image: naeel/sless-funcs-service:v0.1.3
image: naeel/sless-funcs-service:v0.1.11
ports:
- containerPort: 8090
env:
+644 -24
View File
@@ -188,15 +188,25 @@
color: #8b949e;
}
.fn-url-row {
padding: 0 14px 9px 14px;
display: flex;
align-items: center;
gap: 6px;
}
.fn-url-row:empty {
display: none;
}
.fn-url {
font-size: 0.76rem;
color: #58a6ff;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
flex-shrink: 1;
white-space: nowrap;
max-width: 100%;
}
.fn-url:hover {
@@ -312,6 +322,197 @@
color: #484f58;
font-size: 0.9rem;
}
/* Кнопки действий на карточке функции */
.action-btn {
flex-shrink: 0;
padding: 3px 10px;
border-radius: 5px;
border: 1px solid #30363d;
cursor: pointer;
font-size: 0.73rem;
font-weight: 500;
background: #21262d;
color: #8b949e;
transition: background 0.1s, color 0.1s;
}
.action-btn:hover { background: #30363d; color: #c9d1d9; }
.action-btn.del:hover { background: #3d1a1a; border-color: #6b1a1a; color: #f85149; }
/* Кодовый редактор во встроенном режиме */
.edit-area {
width: 100%;
min-height: 220px;
background: #0d1117;
color: #e6edf3;
border: none;
border-top: 1px solid #30363d;
padding: 14px 16px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.78rem;
line-height: 1.55;
resize: vertical;
outline: none;
display: block;
box-sizing: border-box;
}
.edit-actions {
display: flex;
gap: 8px;
padding: 8px 14px;
background: #161b22;
border-top: 1px solid #30363d;
}
.edit-save-btn {
padding: 5px 16px;
background: #238636;
border: 1px solid #2ea043;
color: #fff;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
}
.edit-save-btn:hover { background: #2ea043; }
.edit-save-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.edit-cancel-btn {
padding: 5px 16px;
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
}
.edit-status {
font-size: 0.77rem;
padding: 5px 8px;
align-self: center;
color: #8b949e;
}
.edit-status.ok { color: #3fb950; }
.edit-status.err { color: #f85149; }
/* ---------- Модалка создания функции ---------- */
.modal-backdrop {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.65);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-backdrop.open { display: flex; }
.modal {
background: #161b22;
border: 1px solid #30363d;
border-radius: 10px;
width: min(660px, 95vw);
max-height: 90vh;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal h2 {
font-size: 1rem;
font-weight: 600;
color: #e6edf3;
}
.modal label {
font-size: 0.82rem;
color: #8b949e;
display: block;
margin-bottom: 4px;
}
.modal input, .modal select {
width: 100%;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
padding: 7px 10px;
font-size: 0.85rem;
box-sizing: border-box;
outline: none;
}
.modal input:focus, .modal select:focus { border-color: #58a6ff; }
.modal-row { display: flex; gap: 12px; }
.modal-row > div { flex: 1; }
.modal-code {
width: 100%;
min-height: 200px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
padding: 10px 12px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.78rem;
line-height: 1.55;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.modal-code:focus { border-color: #58a6ff; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.modal-create-btn {
padding: 7px 20px;
background: #238636;
border: 1px solid #2ea043;
color: #fff;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.modal-create-btn:hover { background: #2ea043; }
.modal-create-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.modal-cancel-btn {
padding: 7px 20px;
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.modal-err {
color: #f85149;
font-size: 0.8rem;
min-height: 18px;
}
.create-fn-btn {
margin-left: auto;
background: #238636;
border: 1px solid #2ea043;
color: #fff;
padding: 6px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.82rem;
transition: background 0.1s;
}
.create-fn-btn:hover { background: #2ea043; }
/* Таймер Building */
.fn-timer {
font-size: 0.72rem;
color: #d29922;
font-family: monospace;
margin-left: 2px;
flex-shrink: 0;
}
.badge-Building {
animation: pulse-bld 1.4s ease-in-out infinite;
}
@keyframes pulse-bld {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
</head>
@@ -321,12 +522,56 @@
<span class="logo-slash">/</span>
<span class="ns-badge" id="ns-label"></span>
<button class="refresh-btn" onclick="location.reload()">↻ обновить</button>
<button class="create-fn-btn" id="open-create-modal">+ Создать функцию</button>
</header>
<main>
<p class="summary" id="summary"></p>
<div id="fn-list"></div>
</main>
<!-- Модалка создания новой функции -->
<div class="modal-backdrop" id="create-modal">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Создать функцию</h2>
<div>
<label>Runtime</label>
<select id="m-runtime">
<option value="python3.11">Python 3.11</option>
<option value="nodejs20">Node.js 20</option>
</select>
</div>
<div class="modal-row">
<div>
<label>Имя функции (k8s name)</label>
<input id="m-name" type="text" placeholder="my-hello-fn" pattern="[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]">
</div>
<div>
<label>Entrypoint (module.func)</label>
<input id="m-entrypoint" type="text" value="handler.handler">
</div>
</div>
<div>
<label id="m-filename-label">Файл: <code id="m-filename-display">handler.py</code></label>
<textarea class="modal-code" id="m-code" spellcheck="false"></textarea>
</div>
<div>
<label><span id="m-deps-name">requirements.txt</span> <span style="color:#484f58;font-size:0.75rem">(зависимости, опционально)</span></label>
<textarea class="modal-code" id="m-deps" spellcheck="false" style="min-height:80px"></textarea>
</div>
<div class="modal-err" id="m-err"></div>
<div class="modal-actions">
<button class="modal-cancel-btn" id="modal-cancel">Отмена</button>
<button class="modal-create-btn" id="modal-submit">Создать</button>
</div>
</div>
</div>
<script id="fndata" type="application/json">__PAGE_DATA__</script>
<script>
(function () {
@@ -355,6 +600,8 @@
list.appendChild(buildCard(fns[i], byFn[fns[i].name] || []));
}
// ---------- Карточки функций ----------
function buildCard(fn, triggers) {
var card = $el('div', 'fn-card');
var hdr = $el('div', 'fn-header');
@@ -363,40 +610,46 @@
$txt(hdr, 'span', 'fn-name', fn.name);
$txt(hdr, 'span', 'badge badge-runtime', fn.runtime);
$txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase || 'Pending');
var phaseBadge = $txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase || 'Pending');
if (fn.kind) {
var kindLabel = fn.kind === 'service' ? 'always-on' : 'job';
$txt(hdr, 'span', 'badge badge-kind-' + fn.kind, kindLabel);
}
// sless_service имеет прямой URL из статуса деплоя
if (fn.url) {
// Для Building/Pending — таймер секунд + автополинг до Ready/Failed
if (fn.phase === 'Building' || fn.phase === 'Pending') {
var timerEl = $txt(hdr, 'span', 'fn-timer', '0с');
startBuildPoll(phaseBadge, timerEl, fn, ns, null, function(url) {
if (!fn.url) { fn.url = url; setCardUrl(url); }
});
}
if (fn.message) $txt(hdr, 'span', 'fn-msg', fn.message);
hdr.appendChild($el('span', 'spacer'));
// urlRow — отдельная строка под заголовком для URL
var urlRow = $el('div', 'fn-url-row');
var cardUrlAnchor = null;
function setCardUrl(url) {
if (cardUrlAnchor) return; // уже есть
var a = document.createElement('a');
a.className = 'fn-url';
a.href = fn.url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = fn.url;
a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.textContent = url;
a.addEventListener('click', function (e) { e.stopPropagation(); });
hdr.appendChild(a);
urlRow.appendChild(a);
cardUrlAnchor = a;
}
if (fn.url) setCardUrl(fn.url);
for (var t = 0; t < triggers.length; t++) {
var tr = triggers[t];
if (tr.type === 'http') {
// Для job-style функций URL берём из триггера; для сервисов уже показан fn.url
if (!fn.url) {
var url = extURL ? extURL + '/fn/' + ns + '/' + fn.name : (tr.url || '');
if (url) {
var a = document.createElement('a');
a.className = 'fn-url';
a.href = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = url;
a.addEventListener('click', function (e) { e.stopPropagation(); });
hdr.appendChild(a);
}
if (url) setCardUrl(url);
}
hdr.appendChild(makeTriggerBtn(tr, ns));
} else if (tr.type === 'cron') {
@@ -405,8 +658,44 @@
}
}
if (fn.message) $txt(hdr, 'span', 'fn-msg', fn.message);
hdr.appendChild($el('span', 'spacer'));
// Кнопка редактирования кода
var editBtn = $el('button', 'action-btn');
editBtn.textContent = '✎';
editBtn.title = 'Редактировать код';
editBtn.addEventListener('click', function (e) {
e.stopPropagation();
body.innerHTML = ''; // сбрасываем содержимое перед переходом в edit
loaded = false; // следующий expand снова зачитает исходники
body.style.display = 'block';
icon.style.transform = 'rotate(90deg)';
openEditMode(body, ns, fn.name, fn.kind, fn.runtime);
});
hdr.appendChild(editBtn);
// Кнопка удаления
var delBtn = $el('button', 'action-btn del');
delBtn.textContent = '✕';
delBtn.title = 'Удалить функцию';
delBtn.addEventListener('click', function (e) {
e.stopPropagation();
if (!confirm('Удалить функцию «' + fn.name + '»?\n\nЭто действие необратимо.')) return;
delBtn.disabled = true;
fetch('/funcs/' + ns + '/delete-function/' + fn.name + '?kind=' + (fn.kind || 'function'), {
method: 'DELETE'
}).then(function (r) {
if (r.ok) {
card.remove();
} else {
return r.text().then(function (t) { alert('Ошибка удаления: ' + t); });
}
}).catch(function (err) {
alert('Сетевая ошибка: ' + err);
}).finally(function () {
delBtn.disabled = false;
});
});
hdr.appendChild(delBtn);
var icon = $txt(hdr, 'span', 'expand-icon', '▶');
var loaded = false;
@@ -425,10 +714,226 @@
});
card.appendChild(hdr);
card.appendChild(urlRow);
card.appendChild(body);
return card;
}
// openEditMode — открывает тело карточки в режиме редактирования.
// Загружает текущий код и файл зависимостей (requirements.txt / package.json).
function openEditMode(body, ns, fnName, fnKind, fnRuntime) {
body.innerHTML = '';
var msg = $el('div', 'src-msg');
msg.textContent = 'Загрузка кода…';
body.appendChild(msg);
var kindParam = fnKind === 'service' ? '?kind=service' : '';
fetch('/funcs/' + ns + '/source/' + fnName + kindParam)
.then(function (r) {
body.removeChild(msg);
if (!r.ok) {
var e = $el('div', 'src-msg');
e.textContent = 'Ошибка загрузки кода: ' + r.status;
body.appendChild(e);
return;
}
return r.json().then(function (files) {
var editable = files.filter(function (f) {
return !f.binary && (f.name.endsWith('.py') || f.name.endsWith('.js'));
});
if (editable.length === 0) {
editable = [{ name: defaultFilename(fnRuntime), content: helloWorldTemplate(fnRuntime) }];
}
var mainFile = editable[0];
// Ищем файл зависимостей (requirements.txt / package.json)
var tpl = TEMPLATES[fnRuntime] || {};
var depsFilename = tpl.depsFilename || '';
var depsContent = tpl.deps || '';
if (depsFilename) {
for (var i = 0; i < files.length; i++) {
if (files[i].name === depsFilename && !files[i].binary) {
depsContent = files[i].content;
break;
}
}
}
renderEditArea(body, ns, fnName, fnKind, fnRuntime,
mainFile.name, mainFile.content, depsFilename, depsContent);
});
})
.catch(function (err) {
body.innerHTML = '';
var e = $el('div', 'src-msg');
e.textContent = 'Ошибка: ' + err;
body.appendChild(e);
});
}
// renderEditArea — rename input + code textarea + deps textarea + Save/Cancel.
function renderEditArea(body, ns, fnName, fnKind, fnRuntime, filename, initialCode, depsFilename, depsContent) {
body.innerHTML = '';
// Строка переименования
var renameRow = $el('div', 'edit-actions');
renameRow.style.borderBottom = '1px solid #30363d';
var nameLabel = $el('span', 'edit-status');
nameLabel.textContent = 'Имя:';
var nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.value = fnName;
nameInput.style.cssText = 'flex:1;max-width:240px;background:#0d1117;border:1px solid #30363d;border-radius:5px;color:#e6edf3;padding:4px 8px;font-size:0.8rem;outline:none;';
var renameBtn = $el('button', 'edit-cancel-btn');
renameBtn.textContent = 'Переименовать';
var renameStatus = $el('span', 'edit-status');
renameRow.appendChild(nameLabel);
renameRow.appendChild(nameInput);
renameRow.appendChild(renameBtn);
renameRow.appendChild(renameStatus);
body.appendChild(renameRow);
// Код функции
var codeHdr = $el('div', 'file-header');
codeHdr.textContent = filename;
body.appendChild(codeHdr);
var ta = document.createElement('textarea');
ta.className = 'edit-area';
ta.spellcheck = false;
ta.value = initialCode;
body.appendChild(ta);
// Файл зависимостей (если есть)
var depsTA = null;
if (depsFilename) {
var depsHdr = $el('div', 'file-header');
depsHdr.textContent = depsFilename;
body.appendChild(depsHdr);
depsTA = document.createElement('textarea');
depsTA.className = 'edit-area';
depsTA.style.minHeight = '80px';
depsTA.spellcheck = false;
depsTA.value = depsContent;
body.appendChild(depsTA);
}
// Кнопки Save / Cancel
var actions = $el('div', 'edit-actions');
var saveBtn = $el('button', 'edit-save-btn');
saveBtn.textContent = 'Сохранить';
var cancelBtn = $el('button', 'edit-cancel-btn');
cancelBtn.textContent = 'Закрыть';
var status = $el('span', 'edit-status');
actions.appendChild(saveBtn);
actions.appendChild(cancelBtn);
actions.appendChild(status);
body.appendChild(actions);
saveBtn.addEventListener('click', function () {
saveBtn.disabled = true;
status.textContent = 'Сохраняю…';
status.className = 'edit-status';
fetch('/funcs/' + ns + '/save-function/' + fnName, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: ta.value, filename: filename, kind: fnKind || 'function',
deps: depsTA ? depsTA.value : '',
deps_filename: depsFilename || ''
})
}).then(function (r) {
saveBtn.disabled = false;
if (r.ok) {
// Ищем phaseBadge текущей карточки через DOM
var cardEl = body.closest ? body.closest('.fn-card') : null;
var pb = cardEl ? cardEl.querySelector('.badge-Ready, .badge-Building, .badge-Pending, .badge-Failed, .badge-Error') : null;
if (pb) { pb.className = 'badge badge-Building'; pb.textContent = 'Building'; }
status.textContent = '⏳ Сборка… 0с';
status.className = 'edit-status';
// Запускаем поллинг: обновляет статус в строке редактора и badge карточки
startBuildPoll(pb, null, { name: fnName, kind: fnKind || 'function' }, ns, status, null);
} else {
return r.text().then(function (t) {
status.textContent = '✗ ' + t;
status.className = 'edit-status err';
});
}
}).catch(function (err) {
saveBtn.disabled = false;
status.textContent = '✗ ' + err;
status.className = 'edit-status err';
});
});
cancelBtn.addEventListener('click', function () { body.style.display = 'none'; });
renameBtn.addEventListener('click', function () {
var newName = nameInput.value.trim();
if (newName === fnName) { renameStatus.textContent = 'Имя не изменилось'; return; }
if (!/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/.test(newName)) {
renameStatus.textContent = '✗ Только строчные буквы, цифры, дефисы';
renameStatus.className = 'edit-status err';
return;
}
renameBtn.disabled = true;
renameStatus.textContent = 'Переименовываю…';
renameStatus.className = 'edit-status';
fetch('/funcs/' + ns + '/rename-function/' + fnName, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_name: newName, kind: fnKind || 'function' })
}).then(function (r) {
if (r.ok) {
renameStatus.textContent = '✓ Переименовано → ' + newName;
renameStatus.className = 'edit-status ok';
setTimeout(function () { location.reload(); }, 800);
} else {
return r.text().then(function (t) {
renameStatus.textContent = '✗ ' + t;
renameStatus.className = 'edit-status err';
});
}
}).catch(function (err) {
renameStatus.textContent = '✗ ' + err;
renameStatus.className = 'edit-status err';
}).finally(function () { renameBtn.disabled = false; });
});
}
// startBuildPoll — опрашивает /fn-status каждые 5с пока phase=Building/Pending.
// Показывает таймер-строку в statusEl (если передан), обновляет phaseBadge,
// добавляет URL через onUrl-коллбэк когда сервис появляется.
function startBuildPoll(phaseBadge, timerEl, fn, ns, statusEl, onUrl) {
var buildStart = Date.now();
var timerItv = setInterval(function () {
var sec = Math.floor((Date.now() - buildStart) / 1000);
if (timerEl && timerEl.parentNode) timerEl.textContent = sec + 'с';
if (statusEl) statusEl.textContent = '⏳ Сборка… ' + sec + 'с';
}, 1000);
var pollItv = setInterval(function () {
fetch('/funcs/' + ns + '/fn-status/' + fn.name + '?kind=' + (fn.kind || 'function'))
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.phase || d.phase === 'Building' || d.phase === 'Pending') return;
clearInterval(timerItv);
clearInterval(pollItv);
if (phaseBadge) {
phaseBadge.className = 'badge badge-' + d.phase;
phaseBadge.textContent = d.phase;
}
if (timerEl && timerEl.parentNode) timerEl.parentNode.removeChild(timerEl);
if (statusEl) {
statusEl.textContent = d.phase === 'Ready'
? '✓ Сборка завершена — функция готова'
: '✗ Сборка завершилась со статусом: ' + d.phase;
statusEl.className = 'edit-status ' + (d.phase === 'Ready' ? 'ok' : 'err');
}
if (d.url && onUrl) onUrl(d.url);
})
.catch(function () {});
}, 5000);
}
function makeTriggerBtn(tr, ns) {
var btn = $el('button', 'trigger-btn ' + (tr.enabled ? 'state-on' : 'state-off'));
btn.textContent = tr.enabled ? '■ стоп' : '▶ запуск';
@@ -459,6 +964,7 @@
}
function loadSource(body, ns, fnName, fnKind) {
body.innerHTML = ''; // всегда очищаем перед загрузкой
var msg = $el('div', 'src-msg');
msg.textContent = 'Загрузка кода…';
body.appendChild(msg);
@@ -506,6 +1012,120 @@
});
}
// ---------- Модалка создания функции ----------
var TEMPLATES = {
'python3.11': {
filename: 'handler.py',
code: '# Hello World — Python 3.11\n# Entrypoint: handler.handler\n\ndef handler(event):\n name = event.get("name", "World")\n return {"result": f"Hello, {name}!"}\n',
depsFilename: 'requirements.txt',
deps: '# Python packages\n# e.g.: requests==2.31.0\n'
},
'nodejs20': {
filename: 'handler.js',
code: '// Hello World — Node.js 20\n// Entrypoint: handler.handler\n\nexports.handler = async (event) => {\n const name = event.name || \'World\';\n return { result: `Hello, ${name}!` };\n};\n',
depsFilename: 'package.json',
deps: '{\n "dependencies": {}\n}\n'
}
};
function defaultFilename(runtime) {
return TEMPLATES[runtime] ? TEMPLATES[runtime].filename : 'handler.py';
}
function helloWorldTemplate(runtime) {
return TEMPLATES[runtime] ? TEMPLATES[runtime].code : '# код функции\n';
}
var modal = document.getElementById('create-modal');
var mRuntime = document.getElementById('m-runtime');
var mCode = document.getElementById('m-code');
var mFilenameDisplay = document.getElementById('m-filename-display');
var mErr = document.getElementById('m-err');
// Заполняем шаблон при открытии / смене runtime
function applyTemplate() {
var tpl = TEMPLATES[mRuntime.value];
if (tpl) {
mCode.value = tpl.code;
mFilenameDisplay.textContent = tpl.filename;
document.getElementById('m-deps-name').textContent = tpl.depsFilename;
document.getElementById('m-deps').value = tpl.deps || '';
}
}
applyTemplate();
mRuntime.addEventListener('change', applyTemplate);
document.getElementById('open-create-modal').addEventListener('click', function () {
applyTemplate();
mErr.textContent = '';
document.getElementById('m-name').value = '';
document.getElementById('m-entrypoint').value = 'handler.handler';
modal.classList.add('open');
document.getElementById('m-name').focus();
});
document.getElementById('modal-cancel').addEventListener('click', function () {
modal.classList.remove('open');
});
modal.addEventListener('click', function (e) {
if (e.target === modal) modal.classList.remove('open');
});
document.getElementById('modal-submit').addEventListener('click', function () {
var name = document.getElementById('m-name').value.trim();
var runtime = mRuntime.value;
var entrypoint = document.getElementById('m-entrypoint').value.trim();
var code = mCode.value;
var filename = TEMPLATES[runtime] ? TEMPLATES[runtime].filename : 'handler.py';
mErr.textContent = '';
if (!name) { mErr.textContent = 'Укажите имя функции'; return; }
if (!/^[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]$/.test(name) && !/^[a-z0-9]$/.test(name)) {
mErr.textContent = 'Имя должно быть в формате k8s: строчные буквы, цифры, дефисы';
return;
}
if (!entrypoint) { mErr.textContent = 'Укажите entrypoint (например handler.handler)'; return; }
if (!code.trim()) { mErr.textContent = 'Напишите код функции'; return; }
var btn = document.getElementById('modal-submit');
btn.disabled = true;
mErr.textContent = 'Создаю…';
fetch('/funcs/' + ns + '/create-function', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name,
runtime: runtime,
kind: 'service',
code: code,
filename: filename,
entrypoint: entrypoint,
memory_mb: 128,
timeout_sec: 30,
deps: document.getElementById('m-deps').value,
deps_filename: TEMPLATES[runtime] ? TEMPLATES[runtime].depsFilename : ''
})
}).then(function (r) {
if (r.ok) {
modal.classList.remove('open');
mErr.textContent = '';
// Перезагружаем страницу чтобы увидеть новую функцию
setTimeout(function () { location.reload(); }, 400);
} else {
return r.text().then(function (t) {
mErr.textContent = '✗ ' + t;
});
}
}).catch(function (err) {
mErr.textContent = '✗ Сетевая ошибка: ' + err;
}).finally(function () {
btn.disabled = false;
});
});
// ---------- Утилиты ----------
function detectLang(name) {
var ext = name.split('.').pop().toLowerCase();
var m = {
+398 -7
View File
@@ -1,15 +1,20 @@
// Изменено: 2026-03-21 (добавлен вывод sless_service вместе с функциями — пользователь видит оба типа как «функции»)
// Изменено: 2026-03-23 (deps поддержка, Building poll, rename, kind=service по умолч.)
// main.go — глобальный HTTP сервис листинга функций пользователя.
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
//
// Маршруты:
// GET /funcs — usage hint (без токена)
// GET /funcs?token=<jwt> — листинг через JWT токен
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
// GET /funcs/<namespace>/source/<fn> — прокси к оператору: файлы исходного кода (JSON)
// PATCH /funcs/<namespace>/triggers/<n> — прокси к оператору: enable/disable триггера
// GET /health — liveness/readiness probe
// GET /funcs — usage hint (без токена)
// GET /funcs?token=<jwt> — листинг через JWT токен
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
// GET /funcs/<namespace>/source/<fn> — прокси: исходный код (JSON)
// PATCH /funcs/<namespace>/triggers/<n> — прокси: enable/disable триггера
// GET /funcs/<namespace>/fn-status/<fn> — прокси: текущий phase+url функции (для UI polling)
// POST /funcs/<namespace>/create-function — создать функцию через UI (kind=service по умолч.)
// POST /funcs/<namespace>/save-function/<fn> — обновить код через UI (zip с code+deps)
// POST /funcs/<namespace>/rename-function/<fn> — переименовать: delete old + create new с тем же кодом
// DELETE /funcs/<namespace>/delete-function/<fn> — удалить функцию через UI
// GET /health — liveness/readiness probe
//
// Env vars:
// SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090)
@@ -21,7 +26,9 @@
package main
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
_ "embed"
"encoding/base64"
@@ -29,6 +36,7 @@ import (
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"regexp"
@@ -180,9 +188,51 @@ func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[st
}
proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "fn-status":
// GET /funcs/{ns}/fn-status/{fn} — текущий phase+url для UI polling
if r.Method != http.MethodGet {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxyFnStatus(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "save-function":
// POST /funcs/{ns}/save-function/{fnName} — обновить код функции через UI
if r.Method != http.MethodPost {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxySaveFunction(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "rename-function":
// POST /funcs/{ns}/rename-function/{fn} — переименовать: delete+create с тем же кодом
if r.Method != http.MethodPost {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxyRenameFunction(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "delete-function":
// DELETE /funcs/{ns}/delete-function/{fnName} — удалить функцию через UI
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxyDeleteFunction(w, r, operatorURL, serviceToken, ns, parts[2])
return
}
}
if len(parts) == 2 && parts[1] == "create-function" {
// POST /funcs/{ns}/create-function — создать новую функцию через UI
if r.Method != http.MethodPost {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxyCreateFunction(w, r, operatorURL, serviceToken, ns)
return
}
if len(parts) > 1 {
http.NotFound(w, r)
return
@@ -549,3 +599,344 @@ func env(key, fallback string) string {
}
return fallback
}
// proxyCreateFunction обрабатывает POST /funcs/{ns}/create-function.
// Создаёт функцию как sless_service (always-on, с URL) если kind не указан.
// Принимает code (основной файл) + deps (requirements.txt / package.json).
func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns string) {
var req struct {
Name string `json:"name"`
Runtime string `json:"runtime"`
Kind string `json:"kind"` // "service" | "function"; default "service"
Code string `json:"code"`
Filename string `json:"filename"`
Deps string `json:"deps"` // содержимое requirements.txt / package.json
DepsFile string `json:"deps_filename"` // "requirements.txt" или "package.json"
Entrypoint string `json:"entrypoint"`
MemoryMB int `json:"memory_mb"`
TimeoutSec int `json:"timeout_sec"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
if req.Name == "" || req.Runtime == "" || req.Code == "" || req.Filename == "" || req.Entrypoint == "" {
http.Error(w, `{"error":"name, runtime, code, filename, entrypoint are required"}`, http.StatusBadRequest)
return
}
if !isValidK8sName(req.Name) {
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
return
}
if req.Kind == "" {
req.Kind = "service" // always-on по умолчанию — сразу получает URL
}
if req.MemoryMB <= 0 {
req.MemoryMB = 128
}
if req.TimeoutSec <= 0 {
req.TimeoutSec = 30
}
resourceType := "functions"
if req.Kind == "service" {
resourceType = "services"
}
// Шаг 1: создаём CRD через оператор (/functions или /services)
createBody, _ := json.Marshal(map[string]any{
"name": req.Name,
"runtime": req.Runtime,
"entrypoint": req.Entrypoint,
"memory_mb": req.MemoryMB,
"timeout_sec": req.TimeoutSec,
})
createURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType
createResp, err := operatorRequest(r.Context(), http.MethodPost, createURL, serviceToken, "application/json", bytes.NewReader(createBody))
if err != nil || (createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK) {
code := http.StatusBadGateway
msg := "create CRD failed"
if createResp != nil {
b, _ := io.ReadAll(createResp.Body)
createResp.Body.Close()
msg = string(b)
code = createResp.StatusCode
}
http.Error(w, msg, code)
return
}
createResp.Body.Close()
// Шаг 2: упаковываем code + deps в zip и загружаем
zipFiles := map[string]string{req.Filename: req.Code}
if req.Deps != "" && req.DepsFile != "" {
zipFiles[req.DepsFile] = req.Deps
}
zipData, err := buildZipFiles(zipFiles)
if err != nil {
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
return
}
uploadURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + req.Name + "/upload"
if err := uploadZipToOperator(r.Context(), uploadURL, serviceToken, req.Filename, zipData); err != nil {
http.Error(w, "upload code: "+err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, `{"status":"ok","name":%q,"kind":%q}`, req.Name, req.Kind)
}
// proxySaveFunction обрабатывает POST /funcs/{ns}/save-function/{fnName}.
// Принимает code + deps (опционально), упаковывает в zip и загружает через оператор.
// kind=service → загружает в /services/{name}/upload, иначе → /functions/{name}/upload.
func proxySaveFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
if !isValidK8sName(fnName) {
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
return
}
var req struct {
Code string `json:"code"`
Filename string `json:"filename"`
Kind string `json:"kind"` // "function" | "service"
Deps string `json:"deps"` // содержимое requirements.txt / package.json
DepsFile string `json:"deps_filename"` // имя файла зависимостей
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Code == "" || req.Filename == "" {
http.Error(w, `{"error":"code and filename are required"}`, http.StatusBadRequest)
return
}
zipFiles := map[string]string{req.Filename: req.Code}
if req.Deps != "" && req.DepsFile != "" {
zipFiles[req.DepsFile] = req.Deps
}
zipData, err := buildZipFiles(zipFiles)
if err != nil {
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
return
}
resourceType := "functions"
if req.Kind == "service" {
resourceType = "services"
}
uploadURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + fnName + "/upload"
if err := uploadZipToOperator(r.Context(), uploadURL, serviceToken, req.Filename, zipData); err != nil {
http.Error(w, "upload code: "+err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
}
// proxyDeleteFunction обрабатывает DELETE /funcs/{ns}/delete-function/{fnName}.
// Проксирует DELETE к оператору. kind=service → /services/{name}, иначе → /functions/{name}.
func proxyDeleteFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
if !isValidK8sName(fnName) {
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
return
}
resourceType := "functions"
if r.URL.Query().Get("kind") == "service" {
resourceType = "services"
}
target := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + fnName
resp, err := operatorRequest(r.Context(), http.MethodDelete, target, serviceToken, "", nil)
if err != nil {
http.Error(w, "operator error: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
http.Error(w, string(b), resp.StatusCode)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
}
// buildZipFiles упаковывает набор текстовых файлов {filename→content} в zip-архив.
// Используется при создании/редактировании функций через UI.
// Поддерживает code + deps (requirements.txt / package.json) в одном архиве.
func buildZipFiles(files map[string]string) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, content := range files {
f, err := zw.Create(name)
if err != nil {
return nil, err
}
if _, err := io.WriteString(f, content); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// proxyFnStatus отдаёт текущий phase+url функции/сервиса — используется для UI polling.
// Клиент опрашивает каждые 5с пока phase=Building, потом останавливается.
func proxyFnStatus(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
if !isValidK8sName(fnName) {
http.Error(w, `{"error":"invalid name"}`, http.StatusBadRequest)
return
}
resourceType := "functions"
if r.URL.Query().Get("kind") == "service" {
resourceType = "services"
}
resp, err := operatorRequest(r.Context(), http.MethodGet,
operatorURL+"/v1/namespaces/"+ns+"/"+resourceType+"/"+fnName, serviceToken, "", nil)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(b) //nolint:errcheck
}
// proxyRenameFunction реализует переименование функции: delete old + create new + upload old code.
// Это атомарная операция с точки зрения UI, но не транзакционная на уровне k8s.
// При сбое на шагах 3-5 старая функция может остаться (UI покажет ошибку).
func proxyRenameFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, oldName string) {
if !isValidK8sName(oldName) {
http.Error(w, `{"error":"invalid old name"}`, http.StatusBadRequest)
return
}
var req struct {
NewName string `json:"new_name"`
Kind string `json:"kind"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.NewName == "" {
http.Error(w, `{"error":"new_name required"}`, http.StatusBadRequest)
return
}
if !isValidK8sName(req.NewName) {
http.Error(w, `{"error":"invalid new_name"}`, http.StatusBadRequest)
return
}
resourceType := "functions"
if req.Kind == "service" {
resourceType = "services"
}
ctx := r.Context()
baseURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType
// Шаг 1: получаем конфиг старой функции (runtime, entrypoint, memory, timeout)
cfgResp, err := operatorRequest(ctx, http.MethodGet, baseURL+"/"+oldName, serviceToken, "", nil)
if err != nil || cfgResp.StatusCode != http.StatusOK {
http.Error(w, "get old config failed", http.StatusBadGateway)
return
}
var cfg struct {
Runtime string `json:"runtime"`
Entrypoint string `json:"entrypoint"`
MemoryMB int `json:"memory_mb"`
TimeoutSec int `json:"timeout_sec"`
Env map[string]string `json:"env"`
}
json.NewDecoder(cfgResp.Body).Decode(&cfg) //nolint:errcheck
cfgResp.Body.Close()
// Шаг 2: получаем исходный код старой функции
srcResp, _ := operatorRequest(ctx, http.MethodGet, baseURL+"/"+oldName+"/source", serviceToken, "", nil)
zipFiles := map[string]string{}
if srcResp != nil && srcResp.StatusCode == http.StatusOK {
var files []struct {
Name string `json:"name"`
Content string `json:"content"`
Binary bool `json:"binary"`
}
if json.NewDecoder(srcResp.Body).Decode(&files) == nil {
for _, f := range files {
if !f.Binary {
zipFiles[f.Name] = f.Content
}
}
}
srcResp.Body.Close()
}
// Шаг 3: создаём новую функцию с новым именем
createBody, _ := json.Marshal(map[string]any{
"name": req.NewName, "runtime": cfg.Runtime, "entrypoint": cfg.Entrypoint,
"memory_mb": cfg.MemoryMB, "timeout_sec": cfg.TimeoutSec, "env": cfg.Env,
})
createResp, err := operatorRequest(ctx, http.MethodPost, baseURL, serviceToken, "application/json", bytes.NewReader(createBody))
if err != nil || (createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK) {
msg := "create new failed"
if createResp != nil {
b, _ := io.ReadAll(createResp.Body)
createResp.Body.Close()
msg = string(b)
}
http.Error(w, msg, http.StatusBadGateway)
return
}
createResp.Body.Close()
// Шаг 4: загружаем старый код в новую функцию (если есть)
if len(zipFiles) > 0 {
if zipData, err := buildZipFiles(zipFiles); err == nil {
_ = uploadZipToOperator(ctx, baseURL+"/"+req.NewName+"/upload", serviceToken, req.NewName, zipData)
}
}
// Шаг 5: удаляем старую функцию
delResp, _ := operatorRequest(ctx, http.MethodDelete, baseURL+"/"+oldName, serviceToken, "", nil)
if delResp != nil {
delResp.Body.Close()
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","old":%q,"new":%q,"kind":%q}`, oldName, req.NewName, req.Kind)
}
// uploadZipToOperator отправляет zip-данные как multipart/form-data field "code" на URL оператора.
func uploadZipToOperator(ctx context.Context, url, token, filename string, zipData []byte) error {
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("code", filename+".zip")
if err != nil {
return err
}
if _, err := fw.Write(zipData); err != nil {
return err
}
mw.Close()
resp, err := operatorRequest(ctx, http.MethodPost, url, token, mw.FormDataContentType(), &body)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("status %d: %s", resp.StatusCode, b)
}
return nil
}
// operatorRequest выполняет HTTP-запрос к оператору с авторизацией через serviceToken.
func operatorRequest(ctx context.Context, method, url, token, contentType string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
return http.DefaultClient.Do(req)
}