From d87981713db2478c8a30f44b03c2361ff741bfd5 Mon Sep 17 00:00:00 2001 From: Naeel Date: Thu, 19 Mar 2026 18:45:18 +0300 Subject: [PATCH] =?UTF-8?q?test:=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20POSTGRES=20example=20?= =?UTF-8?q?=E2=80=94=20changes,=20delete/recreate,=20new=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + examples/POSTGRES/code/pg-info/pg_info.js | 1 + examples/POSTGRES/code/pg-stats/pg_stats.py | 38 ++++ .../POSTGRES/code/pg-stats/requirements.txt | 1 + examples/POSTGRES/code/table-rw/table_rw.py | 7 +- examples/POSTGRES/luceUNDnode.tf | 92 ++++---- examples/POSTGRES/resources.tf | 4 +- examples/TNAR/code/funcs-list/funcs_list.py | 94 +++++++++ .../TNAR/code/funcs-list/requirements.txt | 1 + examples/TNAR/code/pg-info/package.json | 8 + examples/TNAR/code/pg-info/pg_info.js | 43 ++++ .../TNAR/code/sql-runner/requirements.txt | 3 + examples/TNAR/code/sql-runner/sql_runner.py | 39 ++++ examples/TNAR/code/table-rw/requirements.txt | 1 + examples/TNAR/code/table-rw/table_rw.py | 130 ++++++++++++ examples/TNAR/funcs_list.py | 129 ++++++++++++ examples/TNAR/luceUNDnode.tf | 73 +++++++ examples/TNAR/main.tf | 58 ++++++ examples/TNAR/resources.tf | 196 ++++++++++++++++++ examples/TNAR/scripts/pg-debug-pod.yaml | 51 +++++ examples/TNAR/scripts/read_pg_user_secret.py | 40 ++++ internal/builder/builder.go | 2 +- 22 files changed, 959 insertions(+), 53 deletions(-) create mode 100644 examples/POSTGRES/code/pg-stats/pg_stats.py create mode 100644 examples/POSTGRES/code/pg-stats/requirements.txt create mode 100644 examples/TNAR/code/funcs-list/funcs_list.py create mode 100644 examples/TNAR/code/funcs-list/requirements.txt create mode 100644 examples/TNAR/code/pg-info/package.json create mode 100644 examples/TNAR/code/pg-info/pg_info.js create mode 100644 examples/TNAR/code/sql-runner/requirements.txt create mode 100644 examples/TNAR/code/sql-runner/sql_runner.py create mode 100644 examples/TNAR/code/table-rw/requirements.txt create mode 100644 examples/TNAR/code/table-rw/table_rw.py create mode 100644 examples/TNAR/funcs_list.py create mode 100644 examples/TNAR/luceUNDnode.tf create mode 100644 examples/TNAR/main.tf create mode 100644 examples/TNAR/resources.tf create mode 100644 examples/TNAR/scripts/pg-debug-pod.yaml create mode 100644 examples/TNAR/scripts/read_pg_user_secret.py 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} + + + {rows_html} +
#titlecreated_at
+ +""" + + +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},