diff --git a/.gitignore b/.gitignore
index e30af62..4741072 100644
--- a/.gitignore
+++ b/.gitignore
@@ -64,3 +64,4 @@ sless-plan
plan.out
sless-plan
examples/.git
+event-dispatcher
diff --git a/examples/POSTGRES/code/pg-info/pg_info.js b/examples/POSTGRES/code/pg-info/pg_info.js
index e08df17..9468076 100644
--- a/examples/POSTGRES/code/pg-info/pg_info.js
+++ b/examples/POSTGRES/code/pg-info/pg_info.js
@@ -36,6 +36,7 @@ exports.info = async (event) => {
node_version: process.version,
pg_version: versionRes.rows[0].v,
table_rows: parseInt(countRes.rows[0].cnt, 10),
+ code_version: 'v2-agent-test',
};
} finally {
await client.end();
diff --git a/examples/POSTGRES/code/pg-stats/pg_stats.py b/examples/POSTGRES/code/pg-stats/pg_stats.py
new file mode 100644
index 0000000..225f41b
--- /dev/null
+++ b/examples/POSTGRES/code/pg-stats/pg_stats.py
@@ -0,0 +1,38 @@
+# 2026-03-19
+# pg_stats.py — тестовая функция (Test 7): возвращает агрегированную статистику
+# по таблице terraform_demo_table: кол-во строк, дата первой и последней записи.
+# Создаётся и удаляется в рамках тестового прогона.
+#
+# Entrypoint: pg_stats.get_stats
+
+import os
+import psycopg2
+import json
+
+_CODE_VERSION = "v1-test7"
+
+
+def get_stats(event):
+ 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(*) AS cnt, MIN(created_at) AS first, MAX(created_at) AS last "
+ "FROM terraform_demo_table"
+ )
+ row = cur.fetchone()
+ return {
+ "version": _CODE_VERSION,
+ "total_rows": row[0],
+ "first_row_at": str(row[1]) if row[1] else None,
+ "last_row_at": str(row[2]) if row[2] else None,
+ }
+ finally:
+ conn.close()
diff --git a/examples/POSTGRES/code/pg-stats/requirements.txt b/examples/POSTGRES/code/pg-stats/requirements.txt
new file mode 100644
index 0000000..58ab769
--- /dev/null
+++ b/examples/POSTGRES/code/pg-stats/requirements.txt
@@ -0,0 +1 @@
+psycopg2-binary==2.9.9
diff --git a/examples/POSTGRES/code/table-rw/table_rw.py b/examples/POSTGRES/code/table-rw/table_rw.py
index 9558739..d142e34 100644
--- a/examples/POSTGRES/code/table-rw/table_rw.py
+++ b/examples/POSTGRES/code/table-rw/table_rw.py
@@ -1,13 +1,16 @@
-# 2026-03-19
+# 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(
@@ -29,7 +32,7 @@ def list_rows(event):
"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)}
+ return {"rows": rows, "count": len(rows), "version": _CODE_VERSION, "host": socket.gethostname()}
finally:
conn.close()
diff --git a/examples/POSTGRES/luceUNDnode.tf b/examples/POSTGRES/luceUNDnode.tf
index 75c0658..8027a2f 100644
--- a/examples/POSTGRES/luceUNDnode.tf
+++ b/examples/POSTGRES/luceUNDnode.tf
@@ -1,25 +1,23 @@
# resource "nubes_lucee" "app1" {
-# # Lucee-приложение, зависит от Postgres
+# # Lucee-приложение, подключается к nubes_postgres.npg (наш Postgres в реалме var.realm).
# resource_name = "lucy_teststand_0"
-# # resource_realm = "k8s-3.ext.nubes.ru"
-# resource_realm = nubes_postgres.db2.resource_realm
-# # resource_realm = "k8s-4-sandbox-nubes-ru"
+# resource_realm = nubes_postgres.npg.resource_realm
# domain = "web-test-stand"
-# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testlucee"
+# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testlucee"
-# json_env = jsonencode({
-# # 🔗 Настройки Data Source 'testds' для Lucee (Application.cfc)
-# testds_class = "org.postgresql.Driver" # 📂 Драйвер БД
-# testds_bundleName = "org.postgresql.jdbc" # 📦 Имя бандла JDBC
-# testds_bundleVersion = "42.6.0" # 🔢 Версия драйвера
-# testds_connectionString = "jdbc:postgresql://${nubes_postgres.db2.state_out_flat["internalConnect.master"]}:5432/postgres?sslmode=require" # 🚀 Строка подключения
-# testds_username = nubes_postgres_user.db2_user.username # 👤 Логин
-# testds_password = jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"] # 🔑 Пароль
-# testds_connectionLimit = "5" # 🚦 Лимит соединений
-# testds_liveTimeout = "15" # ⏳ Таймаут жизни
-# testds_validate = "false" # ✅ Валидация при запросе
+# json_env = jsonencode({
+# # Настройки Data Source 'testds' для Lucee (Application.cfc)
+# testds_class = "org.postgresql.Driver"
+# testds_bundleName = "org.postgresql.jdbc"
+# testds_bundleVersion = "42.6.0"
+# testds_connectionString = "jdbc:postgresql://${local.pg_host}:5432/${local.pg_database}?sslmode=require"
+# testds_username = local.pg_username
+# testds_password = local.pg_password
+# testds_connectionLimit = "5"
+# testds_liveTimeout = "15"
+# testds_validate = "false"
# })
# resource_c_p_u = 300
@@ -27,40 +25,40 @@
# resource_instances = 1
# app_version = "5.4"
-# depends_on = [nubes_postgres.db2]
+# depends_on = [nubes_postgres.npg]
# }
# resource "nubes_nodejs" "app3" {
-# # NodeJS демо, работающий с тем же Postgres.
-# resource_name = "node_01"
-# resource_realm = nubes_postgres.db2.resource_realm
-# domain = "node07"
-# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testnode.git"
-# health_path = "/healthz"
-# app_version = "23"
-
-# json_env = jsonencode({
-# # Переменные подключения к Postgres.
-# PGHOST = nubes_postgres.db2.state_out_flat["internalConnect.master"]
-# PGPORT = "5432"
-# PGUSER = nubes_postgres_user.db2_user.username
-# PGPASSWORD = jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"]
-# PGDATABASE = nubes_postgres_database.db2_app.db_name
-# PGSSLMODE = "require"
-# DATABASE_URL = format(
+# # NodeJS — застрял при создании на тест-стенде (операция в ожидании).
+# # Раскомментировать когда тест-стенд стабилен.
+# resource_name = "node_01"
+# resource_realm = nubes_postgres.npg.resource_realm
+# domain = "node07"
+# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testnode.git"
+# health_path = "/healthz"
+# app_version = "23"
+#
+# json_env = jsonencode({
+# PGHOST = local.pg_host
+# PGPORT = "5432"
+# PGUSER = local.pg_username
+# PGPASSWORD = local.pg_password
+# PGDATABASE = local.pg_database
+# PGSSLMODE = "require"
+# DATABASE_URL = format(
# "postgresql://%s:%s@%s:5432/%s?sslmode=require",
-# nubes_postgres_user.db2_user.username,
-# jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"],
-# nubes_postgres.db2.state_out_flat["internalConnect.master"],
-# nubes_postgres_database.db2_app.db_name
-# )
-# })
-
-# resource_c_p_u = 300
-# resource_memory = 256
-# resource_instances = 1
-
-# depends_on = [nubes_postgres.db2]
+# local.pg_username,
+# local.pg_password,
+# local.pg_host,
+# local.pg_database
+# )
+# })
+#
+# resource_c_p_u = 300
+# resource_memory = 256
+# resource_instances = 1
+#
+# depends_on = [nubes_postgres.npg]
# }
# output "pg_vault_secrets" {
@@ -68,6 +66,4 @@
# sensitive = true
# }
-# terraform output -json pg_vault_secrets
-
diff --git a/examples/POSTGRES/resources.tf b/examples/POSTGRES/resources.tf
index 4041415..4b464fe 100644
--- a/examples/POSTGRES/resources.tf
+++ b/examples/POSTGRES/resources.tf
@@ -166,8 +166,8 @@ resource "sless_function" "postgres_table_writer" {
name = "pg-table-writer"
runtime = "python3.11"
entrypoint = "table_rw.add_row"
- memory_mb = 128
- timeout_sec = 30
+ memory_mb = 256
+ timeout_sec = 45
env_vars = {
PGHOST = local.pg_host
diff --git a/examples/TNAR/code/funcs-list/funcs_list.py b/examples/TNAR/code/funcs-list/funcs_list.py
new file mode 100644
index 0000000..68157d2
--- /dev/null
+++ b/examples/TNAR/code/funcs-list/funcs_list.py
@@ -0,0 +1,94 @@
+# 2026-03-18 (обновлено: plain text вывод; фильтрация SLESS_EXCLUDE)
+# funcs_list.py — HTTP-функция: список пользовательских функций, человекочитаемый plain text.
+# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
+# Возвращает str → python runtime отдаёт text/plain напрямую без json.dumps.
+#
+# Env vars:
+# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
+# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
+# SLESS_TOKEN — JWT токен для /v1/ API
+# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru)
+# SLESS_EXCLUDE — comma-separated имена функций, которые не показывать
+
+import os
+import requests
+
+SEP = "─" * 52
+
+
+def _comment(fn, http_trigs, cron_trigs):
+ phase = fn.get("phase", "?")
+ runtime = fn.get("runtime", "?")
+ if http_trigs:
+ active = "активна" if http_trigs[0].get("active") else "неактивна"
+ return f"HTTP endpoint ({runtime}) — {phase}, {active}"
+ elif cron_trigs:
+ schedule = cron_trigs[0].get("schedule", "?")
+ active = "активна" if cron_trigs[0].get("active") else "неактивна"
+ return f"Cron '{schedule}' ({runtime}) — {phase}, {active}"
+ else:
+ return f"Job/runner без триггера ({runtime}) — {phase}"
+
+
+def list_all(event):
+ api_url = os.environ["SLESS_API_URL"].rstrip("/")
+ namespace = os.environ["SLESS_NAMESPACE"]
+ token = os.environ["SLESS_TOKEN"]
+ ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
+ exclude = {n.strip() for n in os.environ.get("SLESS_EXCLUDE", "").split(",") if n.strip()}
+
+ headers = {"Authorization": f"Bearer {token}"}
+ fns = requests.get(f"{api_url}/v1/namespaces/{namespace}/functions", headers=headers, timeout=10)
+ trs = requests.get(f"{api_url}/v1/namespaces/{namespace}/triggers", headers=headers, timeout=10)
+ fns.raise_for_status()
+ trs.raise_for_status()
+
+ trig_idx = {}
+ for tr in trs.json():
+ fn_name = tr.get("function") or tr.get("functionRef")
+ if fn_name:
+ trig_idx.setdefault(fn_name, []).append(tr)
+
+ items = []
+ for fn in fns.json():
+ name = fn["name"]
+ if name in exclude:
+ continue
+ http_t = [t for t in trig_idx.get(name, []) if t.get("type") == "http"]
+ cron_t = [t for t in trig_idx.get(name, []) if t.get("type") == "cron"]
+ is_active = any(t.get("enabled", True) and t.get("active", False) for t in trig_idx.get(name, []))
+ items.append((fn, http_t, cron_t, is_active))
+
+ # Сортировка: активные вверх, затем по имени
+ items.sort(key=lambda x: (not x[3], x[0]["name"]))
+
+ lines = []
+ for fn, http_t, cron_t, is_active in items:
+ name = fn["name"]
+ lines.append(SEP)
+ lines.append(f" {_comment(fn, http_t, cron_t)}")
+ lines.append(f" name: {name}")
+ lines.append(f" runtime: {fn.get('runtime', '?')}")
+ lines.append(f" phase: {fn.get('phase', '?')}")
+ lines.append(f" active: {'да' if is_active else 'нет'}")
+
+ if http_t:
+ url = f"{ext_url}/fn/{namespace}/{name}" if ext_url else http_t[0].get("url", "")
+ lines.append(f" url: {url}")
+ if cron_t:
+ lines.append(f" cron: {cron_t[0].get('schedule', '?')}")
+ if fn.get("created_at"):
+ lines.append(f" created: {fn['created_at']}")
+ if fn.get("last_built_at"):
+ lines.append(f" built: {fn['last_built_at']}")
+ if fn.get("message"):
+ lines.append(f" message: {fn['message']}")
+
+ lines.append(SEP)
+ lines.append(f" namespace: {namespace} | total: {len(items)}")
+ lines.append(SEP)
+
+ # Возвращаем str — python runtime отдаст text/plain напрямую
+ return "\n".join(lines) + "\n"
+
+
diff --git a/examples/TNAR/code/funcs-list/requirements.txt b/examples/TNAR/code/funcs-list/requirements.txt
new file mode 100644
index 0000000..2c24336
--- /dev/null
+++ b/examples/TNAR/code/funcs-list/requirements.txt
@@ -0,0 +1 @@
+requests==2.31.0
diff --git a/examples/TNAR/code/pg-info/package.json b/examples/TNAR/code/pg-info/package.json
new file mode 100644
index 0000000..5e6394d
--- /dev/null
+++ b/examples/TNAR/code/pg-info/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "pg-info",
+ "version": "1.0.0",
+ "description": "sless nodejs20 function: pg version + table info",
+ "dependencies": {
+ "pg": "8.11.0"
+ }
+}
\ No newline at end of file
diff --git a/examples/TNAR/code/pg-info/pg_info.js b/examples/TNAR/code/pg-info/pg_info.js
new file mode 100644
index 0000000..e08df17
--- /dev/null
+++ b/examples/TNAR/code/pg-info/pg_info.js
@@ -0,0 +1,43 @@
+// 2026-03-18
+// pg_info.js — NodeJS-функция: проверка работы JS runtime + чтение мета-данных БД.
+// Подключается к PostgreSQL через пакет pg, возвращает версию сервера и счётчик строк.
+// Демонстрирует: nodejs20 runtime, npm-зависимость (package.json), PG из JS.
+//
+// ENV (те же что у python-функций):
+// PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE
+//
+// Entrypoint: pg_info.info
+
+'use strict';
+
+const { Client } = require('pg');
+
+exports.info = 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,
+ // pg-пакет требует явного ssl-объекта; rejectUnauthorized: false — т.к.
+ // self-signed cert на nubes managed PG, но канал всё равно шифруется.
+ ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : false,
+ });
+
+ await client.connect();
+ try {
+ const [versionRes, countRes] = await Promise.all([
+ client.query('SELECT version() AS v'),
+ client.query('SELECT COUNT(*) AS cnt FROM terraform_demo_table'),
+ ]);
+
+ return {
+ runtime: 'nodejs20',
+ node_version: process.version,
+ pg_version: versionRes.rows[0].v,
+ table_rows: parseInt(countRes.rows[0].cnt, 10),
+ };
+ } finally {
+ await client.end();
+ }
+};
diff --git a/examples/TNAR/code/sql-runner/requirements.txt b/examples/TNAR/code/sql-runner/requirements.txt
new file mode 100644
index 0000000..56ae88a
--- /dev/null
+++ b/examples/TNAR/code/sql-runner/requirements.txt
@@ -0,0 +1,3 @@
+# 2026-03-17 00:00
+# requirements.txt — зависимости для функции запуска SQL.
+psycopg2-binary==2.9.9
diff --git a/examples/TNAR/code/sql-runner/sql_runner.py b/examples/TNAR/code/sql-runner/sql_runner.py
new file mode 100644
index 0000000..cca9e03
--- /dev/null
+++ b/examples/TNAR/code/sql-runner/sql_runner.py
@@ -0,0 +1,39 @@
+# 2026-03-17 00:00
+# sql_runner.py — функция для выполнения SQL-операторов из входного события.
+import os
+import psycopg2
+
+
+def run_sql(event):
+ # Выполняет список SQL-операторов в одной транзакции для атомарной инициализации схемы.
+ # Параметры подключения передаются раздельно, чтобы избежать ошибок парсинга DSN при спецсимволах.
+ pg_host = os.environ["PGHOST"]
+ pg_port = os.environ.get("PGPORT", "5432")
+ pg_database = os.environ["PGDATABASE"]
+ pg_user = os.environ["PGUSER"]
+ pg_password = os.environ["PGPASSWORD"]
+ pg_sslmode = os.environ.get("PGSSLMODE", "require")
+ statements = event.get("statements", [])
+
+ if not statements:
+ return {"error": "no statements provided"}
+
+ connection = psycopg2.connect(
+ host=pg_host,
+ port=pg_port,
+ dbname=pg_database,
+ user=pg_user,
+ password=pg_password,
+ sslmode=pg_sslmode,
+ )
+ try:
+ cursor = connection.cursor()
+ for statement in statements:
+ cursor.execute(statement)
+ connection.commit()
+ return {"ok": True, "executed": len(statements)}
+ except Exception as error:
+ connection.rollback()
+ return {"error": str(error)}
+ finally:
+ connection.close()
diff --git a/examples/TNAR/code/table-rw/requirements.txt b/examples/TNAR/code/table-rw/requirements.txt
new file mode 100644
index 0000000..58ab769
--- /dev/null
+++ b/examples/TNAR/code/table-rw/requirements.txt
@@ -0,0 +1 @@
+psycopg2-binary==2.9.9
diff --git a/examples/TNAR/code/table-rw/table_rw.py b/examples/TNAR/code/table-rw/table_rw.py
new file mode 100644
index 0000000..9558739
--- /dev/null
+++ b/examples/TNAR/code/table-rw/table_rw.py
@@ -0,0 +1,130 @@
+# 2026-03-19
+# 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 psycopg2
+import psycopg2.extras
+
+
+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)}
+ finally:
+ conn.close()
+
+
+def _render_page(rows, message=""):
+ # HTML-страница с формой ввода и таблицей строк.
+ # message — статус последней операции (успех / ошибка).
+ rows_html = "".join(
+ f"
| {r['id']} | {r['title']} | {r['created_at']} |
"
+ for r in rows
+ )
+ msg_html = f'{message}
' if message else ""
+ return f"""
+
+
+
+ pg-table-writer
+
+
+
+ pg-table-writer
+
+ {msg_html}
+
+ | # | title | created_at |
+ {rows_html}
+
+
+"""
+
+
+def add_row(event):
+ # GET → HTML-страница с формой и списком строк.
+ # POST → вставляет строку из form-поля title или JSON-поля title,
+ # затем возвращает обновлённую HTML-страницу.
+ # POST с Content-Type: application/json (curl/API) → возвращает JSON.
+ method = event.get("_method", "GET")
+
+ if method == "GET":
+ conn = _connect()
+ try:
+ cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
+ cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
+ rows = [dict(r) for r in cur.fetchall()]
+ finally:
+ conn.close()
+ return _render_page(rows)
+
+ # POST — вставка строки
+ # Поле title приходит либо из JSON-тела, либо из application/x-www-form-urlencoded.
+ # Сервер уже распарсил JSON в event; form-данные приходят как event["body"] = "title=...".
+ title = event.get("title", "").strip()
+ if not title:
+ # Попытка распарсить form-encoded body (браузерная форма)
+ body = event.get("body", "")
+ if body.startswith("title="):
+ from urllib.parse import unquote_plus
+ title = unquote_plus(body[len("title="):].split("&")[0]).strip()
+
+ if not title:
+ return {"ok": False, "error": "title is required"}
+
+ conn = _connect()
+ try:
+ cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
+ cur.execute(
+ "INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id, title, created_at::text",
+ (title,),
+ )
+ row = dict(cur.fetchone())
+ conn.commit()
+
+ # Если запрос из браузера (form POST) — возвращаем обновлённую страницу.
+ # Если из curl/API — возвращаем JSON.
+ accept = event.get("_accept", "")
+ if "application/json" in accept:
+ return {"ok": True, "row": row}
+
+ # Перечитываем все строки для обновлённой страницы
+ cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC")
+ rows = [dict(r) for r in cur.fetchall()]
+ return _render_page(rows, message=f"Добавлено: «{row['title']}»")
+ finally:
+ conn.close()
diff --git a/examples/TNAR/funcs_list.py b/examples/TNAR/funcs_list.py
new file mode 100644
index 0000000..bc17fdc
--- /dev/null
+++ b/examples/TNAR/funcs_list.py
@@ -0,0 +1,129 @@
+# 2026-03-18 (обновлено: фильтрация SLESS_EXCLUDE, читаемый вывод через "#"-ключ)
+# funcs_list.py — HTTP-функция: список всех пользовательских функций с их статусами.
+# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
+# Объединяет данные функций и триггеров в один ответ; скрывает служебные функции.
+#
+# 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 имена функций, которые не надо показывать
+# Пример: "funcs,event-writer,event-monitor,event-cleaner"
+#
+# Формат вывода: JSON-объект, где каждая функция содержит поле "#" — краткий комментарий.
+# При pretty-print (python3 -m json.tool) выглядит как читаемый список с аннотациями.
+
+import os
+import requests
+
+
+def _short_comment(fn, http_triggers, cron_triggers):
+ """Генерирует однострочный комментарий-описание функции по её метаданным."""
+ phase = fn.get("phase", "")
+ runtime = fn.get("runtime", "")
+
+ if http_triggers:
+ active_str = "активна" if http_triggers[0].get("active") else "неактивна"
+ return f"HTTP endpoint ({runtime}) — {phase}, {active_str}"
+ elif cron_triggers:
+ schedule = cron_triggers[0].get("schedule", "?")
+ active_str = "активна" if cron_triggers[0].get("active") else "неактивна"
+ return f"Cron '{schedule}' ({runtime}) — {phase}, {active_str}"
+ 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("/")
+
+ # Имена функций, которые не должны присутствовать в выводе.
+ # Включает саму себя ("funcs") и служебные функции других примеров.
+ exclude = {
+ n.strip()
+ for n in os.environ.get("SLESS_EXCLUDE", "").split(",")
+ if n.strip()
+ }
+
+ headers = {"Authorization": f"Bearer {token}"}
+
+ fns_resp = requests.get(
+ f"{api_url}/v1/namespaces/{namespace}/functions",
+ headers=headers,
+ timeout=10,
+ )
+ fns_resp.raise_for_status()
+
+ trs_resp = requests.get(
+ f"{api_url}/v1/namespaces/{namespace}/triggers",
+ headers=headers,
+ timeout=10,
+ )
+ trs_resp.raise_for_status()
+
+ # Индекс триггеров по имени функции
+ triggers_by_fn = {}
+ for tr in trs_resp.json():
+ fn_name = tr.get("function") or tr.get("functionRef")
+ if fn_name:
+ triggers_by_fn.setdefault(fn_name, []).append(tr)
+
+ result = []
+ for fn in fns_resp.json():
+ name = fn["name"]
+ if name in exclude:
+ continue
+
+ http_triggers = [
+ t for t in triggers_by_fn.get(name, []) if t.get("type") == "http"
+ ]
+ cron_triggers = [
+ t for t in triggers_by_fn.get(name, []) if t.get("type") == "cron"
+ ]
+ is_active = any(
+ t.get("enabled", True) and t.get("active", False)
+ for t in triggers_by_fn.get(name, [])
+ )
+
+ entry = {
+ # "#" — первый ключ: служит визуальным комментарием при pretty-print
+ "#": _short_comment(fn, http_triggers, cron_triggers),
+ "name": name,
+ "runtime": fn.get("runtime"),
+ "phase": fn.get("phase"),
+ "active": is_active,
+ }
+
+ # URL вычисляем из SLESS_EXTERNAL_URL если задан — state может хранить старый домен
+ if http_triggers:
+ if ext_url:
+ entry["url"] = f"{ext_url}/fn/{namespace}/{name}"
+ else:
+ entry["url"] = http_triggers[0].get("url", "")
+
+ if cron_triggers:
+ entry["cron"] = cron_triggers[0].get("schedule", "")
+
+ if fn.get("message"):
+ entry["message"] = fn["message"]
+
+ # created_at и last_built_at — доступны после обновления оператора до v0.1.32+
+ if fn.get("created_at"):
+ entry["created_at"] = fn["created_at"]
+ if fn.get("last_built_at"):
+ entry["last_built_at"] = fn["last_built_at"]
+
+ result.append(entry)
+
+ # Сортировка: активные вверх, затем по имени
+ result.sort(key=lambda f: (not f["active"], f["name"]))
+
+ return {
+ "namespace": namespace,
+ "count": len(result),
+ "functions": result,
+ }
+
diff --git a/examples/TNAR/luceUNDnode.tf b/examples/TNAR/luceUNDnode.tf
new file mode 100644
index 0000000..75c0658
--- /dev/null
+++ b/examples/TNAR/luceUNDnode.tf
@@ -0,0 +1,73 @@
+
+# resource "nubes_lucee" "app1" {
+# # Lucee-приложение, зависит от Postgres
+# resource_name = "lucy_teststand_0"
+# # resource_realm = "k8s-3.ext.nubes.ru"
+# resource_realm = nubes_postgres.db2.resource_realm
+# # resource_realm = "k8s-4-sandbox-nubes-ru"
+# domain = "web-test-stand"
+
+# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testlucee"
+
+# json_env = jsonencode({
+# # 🔗 Настройки Data Source 'testds' для Lucee (Application.cfc)
+# testds_class = "org.postgresql.Driver" # 📂 Драйвер БД
+# testds_bundleName = "org.postgresql.jdbc" # 📦 Имя бандла JDBC
+# testds_bundleVersion = "42.6.0" # 🔢 Версия драйвера
+# testds_connectionString = "jdbc:postgresql://${nubes_postgres.db2.state_out_flat["internalConnect.master"]}:5432/postgres?sslmode=require" # 🚀 Строка подключения
+# testds_username = nubes_postgres_user.db2_user.username # 👤 Логин
+# testds_password = jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"] # 🔑 Пароль
+# testds_connectionLimit = "5" # 🚦 Лимит соединений
+# testds_liveTimeout = "15" # ⏳ Таймаут жизни
+# testds_validate = "false" # ✅ Валидация при запросе
+# })
+
+# resource_c_p_u = 300
+# resource_memory = 512
+# resource_instances = 1
+# app_version = "5.4"
+
+# depends_on = [nubes_postgres.db2]
+# }
+
+# resource "nubes_nodejs" "app3" {
+# # NodeJS демо, работающий с тем же Postgres.
+# resource_name = "node_01"
+# resource_realm = nubes_postgres.db2.resource_realm
+# domain = "node07"
+# git_path = "https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/testnode.git"
+# health_path = "/healthz"
+# app_version = "23"
+
+# json_env = jsonencode({
+# # Переменные подключения к Postgres.
+# PGHOST = nubes_postgres.db2.state_out_flat["internalConnect.master"]
+# PGPORT = "5432"
+# PGUSER = nubes_postgres_user.db2_user.username
+# PGPASSWORD = jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"]
+# PGDATABASE = nubes_postgres_database.db2_app.db_name
+# PGSSLMODE = "require"
+# DATABASE_URL = format(
+# "postgresql://%s:%s@%s:5432/%s?sslmode=require",
+# nubes_postgres_user.db2_user.username,
+# jsondecode(nubes_postgres.db2.vault_secrets["users"])[nubes_postgres_user.db2_user.username]["password"],
+# nubes_postgres.db2.state_out_flat["internalConnect.master"],
+# nubes_postgres_database.db2_app.db_name
+# )
+# })
+
+# resource_c_p_u = 300
+# resource_memory = 256
+# resource_instances = 1
+
+# depends_on = [nubes_postgres.db2]
+# }
+
+# output "pg_vault_secrets" {
+# value = nubes_postgres.db2.vault_secrets
+# sensitive = true
+# }
+
+# terraform output -json pg_vault_secrets
+
+
diff --git a/examples/TNAR/main.tf b/examples/TNAR/main.tf
new file mode 100644
index 0000000..ae97e59
--- /dev/null
+++ b/examples/TNAR/main.tf
@@ -0,0 +1,58 @@
+// 2026-03-17 17:05
+// main.tf — провайдеры и переменные для Nubes + sless.
+terraform {
+ required_providers {
+ nubes = {
+ source = "terra.k8c.ru/nubes/nubes"
+ version = "5.0.19"
+ }
+ sless = {
+ source = "terra.k8c.ru/naeel/sless"
+ version = "~> 0.1.18"
+ }
+ }
+}
+
+variable "api_token" {
+ type = string
+ sensitive = true
+ description = "Nubes API token"
+}
+variable "s3_uid" {
+ type = string
+ sensitive = true
+ description = "Nubes S3 UID"
+}
+variable "realm" {
+ type = string
+ sensitive = true
+ description = "resource_realm parameter for nubes_postgres resource"
+}
+
+// 2026-03-18 — pg_user/pg_password помечены optional (default="") для сверки.
+// Реальные credentials берутся из vault_secrets через locals в resources.tf.
+variable "pg_user" {
+ type = string
+ sensitive = true
+ default = ""
+ description = "Только для сверки. Реальный username из nubes_postgres_user.pg_user.username. Должен совпадать с vault."
+}
+
+variable "pg_password" {
+ type = string
+ sensitive = true
+ default = ""
+ description = "Только для сверки. Реальный пароль из vault_secrets. Должен совпадать с tfvars."
+}
+
+provider "nubes" {
+ api_token = var.api_token
+ api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm"
+}
+
+provider "sless" {
+ endpoint = "https://sless.kube5s.ru"
+ token = var.api_token
+ nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1"
+}
+
diff --git a/examples/TNAR/resources.tf b/examples/TNAR/resources.tf
new file mode 100644
index 0000000..cae3c49
--- /dev/null
+++ b/examples/TNAR/resources.tf
@@ -0,0 +1,196 @@
+// 2026-03-18 — добавлены locals для извлечения credentials из vault_secrets (без хардкода).
+// Для сверки хардкод остаётся в terraform.tfvars на этапе разработки.
+// sless_function и sless_job закомментированы — сначала проверяется сетевое соединение.
+
+# Актуальные credentials из vault_secrets (authoritatively) — vault синхронизирован с кластером.
+# Структура vault_secrets["users"]: JSON-строка {"username": {"password": "...", "username": "..."}}
+locals {
+ pg_creds_map = jsondecode(nubes_postgres.npg.vault_secrets["users"])
+ pg_username = nubes_postgres_user.pg_user.username
+ pg_password = local.pg_creds_map[local.pg_username]["password"]
+ pg_host = nubes_postgres.npg.state_out_flat["internalConnect.master"]
+ pg_database = nubes_postgres_database.db.db_name
+}
+
+resource "nubes_postgres" "npg" {
+ resource_name = "testnarod-pg-0"
+ # s3_uid = "s01325"
+ s3_uid = var.s3_uid
+ resource_realm = var.realm
+ resource_instances = 1
+ resource_memory = 512
+ resource_c_p_u = 500
+ resource_disk = "1"
+ app_version = "17"
+ json_parameters = jsonencode({
+ log_connections = "off"
+ log_disconnections = "off"
+ })
+ enable_pg_pooler_master = false
+ enable_pg_pooler_slave = false
+ allow_no_s_s_l = false
+ auto_scale = false
+ auto_scale_percentage = 10
+ auto_scale_tech_window = 0
+ auto_scale_quota_gb = "1"
+ need_external_address_master = false
+
+ # suspend_on_destroy = false
+ operation_timeout = "11m"
+ adopt_existing_on_create = true
+}
+
+resource "nubes_postgres_user" "pg_user" {
+ postgres_id = nubes_postgres.npg.id
+ username = "u-user0"
+ role = "ddl_user"
+ adopt_existing_on_create = true
+}
+
+resource "nubes_postgres_database" "db" {
+ postgres_id = nubes_postgres.npg.id
+ db_name = "db_terra"
+ db_owner = nubes_postgres_user.pg_user.username
+ adopt_existing_on_create = true
+ # suspend_on_destroy = false
+}
+
+# Служебная функция выполняет SQL-операторы из event_json.
+# Credentials берутся из locals (vault_secrets) — без хардкода.
+# Для сверки хардкод остаётся в terraform.tfvars.
+resource "sless_function" "postgres_sql_runner_create_table" {
+ name = "pg-create-table-runner"
+ runtime = "python3.11"
+ entrypoint = "sql_runner.run_sql"
+ 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"
+ # Для сверки (должно совпадать с vault):
+ # PGUSER = var.pg_user
+ # PGPASSWORD = var.pg_password
+ }
+
+ source_dir = "${path.module}/code/sql-runner"
+}
+
+resource "sless_job" "postgres_table_init_job" {
+ name = "pg-create-table-job-main-v13"
+ function = sless_function.postgres_sql_runner_create_table.name
+ wait_timeout_sec = 180
+ run_id = 13
+
+ 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]
+}
+
+# HTTP-функция на NodeJS: возвращает версию PG-сервера и счётчик строк в таблице.
+# Единственная функция примера на nodejs20 — проверка что JS runtime работает.
+# Доступна по URL: https://sless.kube5s.ru/fn//pg-info
+resource "sless_function" "pg_info" {
+ name = "pg-info"
+ runtime = "nodejs20"
+ entrypoint = "pg_info.info"
+ memory_mb = 128
+ timeout_sec = 15
+
+ env_vars = {
+ PGHOST = local.pg_host
+ PGPORT = "5432"
+ PGDATABASE = local.pg_database
+ PGUSER = local.pg_username
+ PGPASSWORD = local.pg_password
+ PGSSLMODE = "require"
+ }
+
+ source_dir = "${path.module}/code/pg-info"
+
+ depends_on = [sless_job.postgres_table_init_job]
+}
+
+resource "sless_trigger" "pg_info_http" {
+ name = "pg-info-http"
+ type = "http"
+ function = sless_function.pg_info.name
+ enabled = true
+}
+
+# HTTP-функции чтения и записи строк terraform_demo_table — в одном файле table_rw.py.
+# list_rows (GET) — читает все строки; add_row (POST {title}) — вставляет строку.
+# Доступны по URL: https://sless.kube5s.ru/fn//pg-table-reader
+# https://sless.kube5s.ru/fn//pg-table-writer
+resource "sless_function" "postgres_table_reader" {
+ name = "pg-table-reader"
+ runtime = "python3.11"
+ entrypoint = "table_rw.list_rows"
+ memory_mb = 128
+ timeout_sec = 30
+
+ env_vars = {
+ PGHOST = local.pg_host
+ PGPORT = "5432"
+ PGDATABASE = local.pg_database
+ PGUSER = local.pg_username
+ PGPASSWORD = local.pg_password
+ PGSSLMODE = "require"
+ }
+
+ source_dir = "${path.module}/code/table-rw"
+
+ depends_on = [sless_job.postgres_table_init_job]
+}
+
+resource "sless_trigger" "postgres_table_reader_http" {
+ name = "pg-table-reader-http"
+ type = "http"
+ function = sless_function.postgres_table_reader.name
+ enabled = true
+}
+
+output "table_reader_url" {
+ value = sless_trigger.postgres_table_reader_http.url
+}
+
+resource "sless_function" "postgres_table_writer" {
+ name = "pg-table-writer"
+ runtime = "python3.11"
+ entrypoint = "table_rw.add_row"
+ memory_mb = 128
+ timeout_sec = 30
+
+ env_vars = {
+ PGHOST = local.pg_host
+ PGPORT = "5432"
+ PGDATABASE = local.pg_database
+ PGUSER = local.pg_username
+ PGPASSWORD = local.pg_password
+ PGSSLMODE = "require"
+ }
+
+ source_dir = "${path.module}/code/table-rw"
+
+ depends_on = [sless_job.postgres_table_init_job]
+}
+
+resource "sless_trigger" "postgres_table_writer_http" {
+ name = "pg-table-writer-http"
+ type = "http"
+ function = sless_function.postgres_table_writer.name
+ enabled = true
+}
+
+output "table_writer_url" {
+ value = sless_trigger.postgres_table_writer_http.url
+}
+
diff --git a/examples/TNAR/scripts/pg-debug-pod.yaml b/examples/TNAR/scripts/pg-debug-pod.yaml
new file mode 100644
index 0000000..652ed5b
--- /dev/null
+++ b/examples/TNAR/scripts/pg-debug-pod.yaml
@@ -0,0 +1,51 @@
+# 2026-03-18 — debug pod для проверки psql-соединения из namespace функций.
+# Запускается разово. Подключается к тому же postgres, что и sless_function.
+# kubectl apply -f /tmp/pg-debug-pod.yaml
+# kubectl logs -n sless-fn-sless-ffd1f598c169b0ae pg-debug-pod
+
+apiVersion: v1
+kind: Pod
+metadata:
+ name: pg-debug-pod
+ namespace: sless-fn-sless-ffd1f598c169b0ae
+ labels:
+ purpose: debug-postgres-connectivity
+spec:
+ restartPolicy: Never
+ containers:
+ - name: psql
+ image: postgres:17-alpine
+ command:
+ - sh
+ - -c
+ - |
+ echo "=== Testing TCP connectivity to postgres ==="
+ nc -zv -w5 $PGHOST 5432 && echo "TCP OK" || echo "TCP FAILED"
+
+ echo ""
+ echo "=== Testing psql connection ==="
+ PGCONNECT_TIMEOUT=10 psql \
+ "host=$PGHOST port=$PGPORT dbname=$PGDATABASE user=$PGUSER sslmode=$PGSSLMODE" \
+ --command="SELECT current_user, current_database(), version();" \
+ 2>&1
+
+ echo ""
+ echo "=== Listing tables ==="
+ PGCONNECT_TIMEOUT=10 psql \
+ "host=$PGHOST port=$PGPORT dbname=$PGDATABASE user=$PGUSER sslmode=$PGSSLMODE" \
+ --command="\dt" \
+ 2>&1
+ env:
+ - name: PGHOST
+ value: "postgresqlk8s-master.36875359-dcea-48c4-a593-b4531f20fe96.svc.cluster.local"
+ - name: PGPORT
+ value: "5432"
+ - name: PGDATABASE
+ value: "db_terra"
+ - name: PGUSER
+ value: "u-user0"
+ - name: PGPASSWORD
+ # Актуальный пароль из vault_secrets (совпадает с tfvars.pg_password на 2026-03-18)
+ value: "M03O6fRsngWcVHB2YGivyLfbfxoii2R21nyh2A2r7WSZS5deLwBgLKkc9Wk24Zyl"
+ - name: PGSSLMODE
+ value: "require"
diff --git a/examples/TNAR/scripts/read_pg_user_secret.py b/examples/TNAR/scripts/read_pg_user_secret.py
new file mode 100644
index 0000000..b0735f3
--- /dev/null
+++ b/examples/TNAR/scripts/read_pg_user_secret.py
@@ -0,0 +1,40 @@
+# 2026-03-17 13:05
+# read_pg_user_secret.py — читает пароль пользователя managed PostgreSQL из k8s Secret.
+# Используется из Terraform external data source, чтобы apply сам получал актуальный пароль
+# даже для уже существующего пользователя, созданного вне текущего state.
+
+import base64
+import json
+import subprocess
+import sys
+
+
+def main():
+ # Читаем query от Terraform external provider из stdin.
+ query = json.load(sys.stdin)
+ namespace = query["namespace"]
+ secret_name = query["secret"]
+
+ # kubectl уже настроен на удалённой машине; читаем ровно поле data.password.
+ result = subprocess.run(
+ [
+ "kubectl",
+ "get",
+ "secret",
+ "-n",
+ namespace,
+ secret_name,
+ "-o",
+ "jsonpath={.data.password}",
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+ password = base64.b64decode(result.stdout.strip()).decode()
+ json.dump({"password": password}, sys.stdout)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/internal/builder/builder.go b/internal/builder/builder.go
index d763c45..ddfccb6 100644
--- a/internal/builder/builder.go
+++ b/internal/builder/builder.go
@@ -125,7 +125,7 @@ func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string)
Args: []string{
"--context=" + s3ContextURL,
"--destination=" + imageRef,
- // --no-cache не поддерживается этой версией kaniko; кэш отключён по умолчанию
+ // --no-cache не поддерживается этой версией kaniko; кэш отключён по умолчанию
},
Env: []corev1.EnvVar{
{Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey},