From 4fa1e71cf2b5d68321615bb9a79911590c6c53f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 19 Mar 2026 10:29:26 +0400 Subject: [PATCH] 1903 --- POSTGRES/code/funcs-list/funcs_list.py | 94 +++++++++ POSTGRES/code/funcs-list/requirements.txt | 1 + POSTGRES/code/pg-info/package.json | 8 + POSTGRES/code/pg-info/pg_info.js | 43 ++++ POSTGRES/code/table-rw/requirements.txt | 1 + POSTGRES/code/table-rw/table_rw.py | 130 ++++++++++++ POSTGRES/funcs_list.py | 129 ++++++++++++ POSTGRES/main.tf | 10 +- POSTGRES/resources.tf | 137 +++++++++++- POSTGRES/scripts/pg-debug-pod.yaml | 51 +++++ README.md | 14 +- TNAR/code/funcs-list/funcs_list.py | 94 +++++++++ TNAR/code/funcs-list/requirements.txt | 1 + TNAR/code/pg-info/package.json | 8 + TNAR/code/pg-info/pg_info.js | 43 ++++ TNAR/code/sql-runner/requirements.txt | 3 + TNAR/code/sql-runner/sql_runner.py | 39 ++++ TNAR/code/table-rw/requirements.txt | 1 + TNAR/code/table-rw/table_rw.py | 130 ++++++++++++ TNAR/funcs_list.py | 129 ++++++++++++ TNAR/luceUNDnode.tf | 73 +++++++ TNAR/main.tf | 58 ++++++ TNAR/resources.tf | 196 ++++++++++++++++++ TNAR/scripts/pg-debug-pod.yaml | 51 +++++ TNAR/scripts/read_pg_user_secret.py | 40 ++++ demo-managed-functions/README.md | 194 +++++++++++++++++ .../terraform.tfvars.example | 4 +- demo-managed-functions/variables.tf | 2 +- hello-go/main.tf | 2 +- hello-node/main.tf | 2 +- notes-python/main.tf | 2 +- pg-list-python/main.tf | 2 +- simple-node/main.tf | 2 +- simple-python/main.tf | 2 +- 34 files changed, 1666 insertions(+), 30 deletions(-) create mode 100644 POSTGRES/code/funcs-list/funcs_list.py create mode 100644 POSTGRES/code/funcs-list/requirements.txt create mode 100644 POSTGRES/code/pg-info/package.json create mode 100644 POSTGRES/code/pg-info/pg_info.js create mode 100644 POSTGRES/code/table-rw/requirements.txt create mode 100644 POSTGRES/code/table-rw/table_rw.py create mode 100644 POSTGRES/funcs_list.py create mode 100644 POSTGRES/scripts/pg-debug-pod.yaml create mode 100644 TNAR/code/funcs-list/funcs_list.py create mode 100644 TNAR/code/funcs-list/requirements.txt create mode 100644 TNAR/code/pg-info/package.json create mode 100644 TNAR/code/pg-info/pg_info.js create mode 100644 TNAR/code/sql-runner/requirements.txt create mode 100644 TNAR/code/sql-runner/sql_runner.py create mode 100644 TNAR/code/table-rw/requirements.txt create mode 100644 TNAR/code/table-rw/table_rw.py create mode 100644 TNAR/funcs_list.py create mode 100644 TNAR/luceUNDnode.tf create mode 100644 TNAR/main.tf create mode 100644 TNAR/resources.tf create mode 100644 TNAR/scripts/pg-debug-pod.yaml create mode 100644 TNAR/scripts/read_pg_user_secret.py create mode 100644 demo-managed-functions/README.md diff --git a/POSTGRES/code/funcs-list/funcs_list.py b/POSTGRES/code/funcs-list/funcs_list.py new file mode 100644 index 0000000..68157d2 --- /dev/null +++ b/POSTGRES/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/POSTGRES/code/funcs-list/requirements.txt b/POSTGRES/code/funcs-list/requirements.txt new file mode 100644 index 0000000..2c24336 --- /dev/null +++ b/POSTGRES/code/funcs-list/requirements.txt @@ -0,0 +1 @@ +requests==2.31.0 diff --git a/POSTGRES/code/pg-info/package.json b/POSTGRES/code/pg-info/package.json new file mode 100644 index 0000000..5e6394d --- /dev/null +++ b/POSTGRES/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/POSTGRES/code/pg-info/pg_info.js b/POSTGRES/code/pg-info/pg_info.js new file mode 100644 index 0000000..e08df17 --- /dev/null +++ b/POSTGRES/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/POSTGRES/code/table-rw/requirements.txt b/POSTGRES/code/table-rw/requirements.txt new file mode 100644 index 0000000..58ab769 --- /dev/null +++ b/POSTGRES/code/table-rw/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary==2.9.9 diff --git a/POSTGRES/code/table-rw/table_rw.py b/POSTGRES/code/table-rw/table_rw.py new file mode 100644 index 0000000..9558739 --- /dev/null +++ b/POSTGRES/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/POSTGRES/funcs_list.py b/POSTGRES/funcs_list.py new file mode 100644 index 0000000..bc17fdc --- /dev/null +++ b/POSTGRES/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/POSTGRES/main.tf b/POSTGRES/main.tf index 9582d07..ae97e59 100644 --- a/POSTGRES/main.tf +++ b/POSTGRES/main.tf @@ -29,16 +29,20 @@ variable "realm" { 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 - description = "PostgreSQL username used by sless SQL runner" + default = "" + description = "Только для сверки. Реальный username из nubes_postgres_user.pg_user.username. Должен совпадать с vault." } variable "pg_password" { type = string sensitive = true - description = "PostgreSQL password used by sless SQL runner" + default = "" + description = "Только для сверки. Реальный пароль из vault_secrets. Должен совпадать с tfvars." } provider "nubes" { @@ -47,7 +51,7 @@ provider "nubes" { } provider "sless" { - endpoint = "https://sless-api.kube5s.ru" + endpoint = "https://sless.kube5s.ru" token = var.api_token nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" } diff --git a/POSTGRES/resources.tf b/POSTGRES/resources.tf index 4f12975..4041415 100644 --- a/POSTGRES/resources.tf +++ b/POSTGRES/resources.tf @@ -1,5 +1,17 @@ -// 2026-03-17 17:30 -// resources.tf — ресурсы Postgres и однократный запуск SQL-инициализации через sless_job. +// 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 = "teststand-pg-2" # s3_uid = "s01325" @@ -44,6 +56,8 @@ resource "nubes_postgres_database" "db" { } # Служебная функция выполняет 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" @@ -52,21 +66,25 @@ resource "sless_function" "postgres_sql_runner_create_table" { timeout_sec = 30 env_vars = { - PGHOST = nubes_postgres.npg.state_out_flat["internalConnect.master"] + PGHOST = local.pg_host PGPORT = "5432" - PGDATABASE = nubes_postgres_database.db.db_name - PGUSER = var.pg_user - PGPASSWORD = var.pg_password + 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-v12" + name = "pg-create-table-job-main-v13" function = sless_function.postgres_sql_runner_create_table.name wait_timeout_sec = 180 - run_id = 12 + run_id = 13 event_json = jsonencode({ statements = [ @@ -74,8 +92,105 @@ resource "sless_job" "postgres_table_init_job" { ] }) - depends_on = [ - nubes_postgres_database.db - ] + 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/POSTGRES/scripts/pg-debug-pod.yaml b/POSTGRES/scripts/pg-debug-pod.yaml new file mode 100644 index 0000000..652ed5b --- /dev/null +++ b/POSTGRES/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/README.md b/README.md index 0e94b03..1fd1bf3 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ | `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. | | `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. | -Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless-api.kube5s.ru/fn//<имя-функции>`. +Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless.kube5s.ru/fn//<имя-функции>`. --- @@ -20,7 +20,7 @@ - Terraform >= 1.0 - JWT-токен для аутентификации в sless API -- Доступ к `https://sless-api.kube5s.ru` +- Доступ к `https://sless.kube5s.ru` ## Конфигурация провайдера @@ -28,9 +28,9 @@ ```hcl provider "sless" { - endpoint = "https://sless-api.kube5s.ru" + endpoint = "https://sless.kube5s.ru" token = var.token - nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" + nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" } ``` @@ -56,7 +56,7 @@ terraform init terraform apply -auto-approve # Вызов HTTP-функции с передачей имени: -curl -s -X POST https://sless-api.kube5s.ru/fn//hello-http \ +curl -s -X POST https://sless.kube5s.ru/fn//hello-http \ -H 'Content-Type: application/json' -d '{"name":"World"}' # Результат задания: @@ -114,7 +114,7 @@ terraform init terraform apply -auto-approve terraform output job_result -curl -s https://sless-api.kube5s.ru/fn//simple-py-time-display +curl -s https://sless.kube5s.ru/fn//simple-py-time-display ``` --- @@ -127,7 +127,7 @@ terraform init terraform apply -auto-approve terraform output job_result -curl -s https://sless-api.kube5s.ru/fn//simple-node-time-display +curl -s https://sless.kube5s.ru/fn//simple-node-time-display ``` --- diff --git a/TNAR/code/funcs-list/funcs_list.py b/TNAR/code/funcs-list/funcs_list.py new file mode 100644 index 0000000..68157d2 --- /dev/null +++ b/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/TNAR/code/funcs-list/requirements.txt b/TNAR/code/funcs-list/requirements.txt new file mode 100644 index 0000000..2c24336 --- /dev/null +++ b/TNAR/code/funcs-list/requirements.txt @@ -0,0 +1 @@ +requests==2.31.0 diff --git a/TNAR/code/pg-info/package.json b/TNAR/code/pg-info/package.json new file mode 100644 index 0000000..5e6394d --- /dev/null +++ b/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/TNAR/code/pg-info/pg_info.js b/TNAR/code/pg-info/pg_info.js new file mode 100644 index 0000000..e08df17 --- /dev/null +++ b/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/TNAR/code/sql-runner/requirements.txt b/TNAR/code/sql-runner/requirements.txt new file mode 100644 index 0000000..56ae88a --- /dev/null +++ b/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/TNAR/code/sql-runner/sql_runner.py b/TNAR/code/sql-runner/sql_runner.py new file mode 100644 index 0000000..cca9e03 --- /dev/null +++ b/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/TNAR/code/table-rw/requirements.txt b/TNAR/code/table-rw/requirements.txt new file mode 100644 index 0000000..58ab769 --- /dev/null +++ b/TNAR/code/table-rw/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary==2.9.9 diff --git a/TNAR/code/table-rw/table_rw.py b/TNAR/code/table-rw/table_rw.py new file mode 100644 index 0000000..9558739 --- /dev/null +++ b/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/TNAR/funcs_list.py b/TNAR/funcs_list.py new file mode 100644 index 0000000..bc17fdc --- /dev/null +++ b/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/TNAR/luceUNDnode.tf b/TNAR/luceUNDnode.tf new file mode 100644 index 0000000..75c0658 --- /dev/null +++ b/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/TNAR/main.tf b/TNAR/main.tf new file mode 100644 index 0000000..ae97e59 --- /dev/null +++ b/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/TNAR/resources.tf b/TNAR/resources.tf new file mode 100644 index 0000000..cae3c49 --- /dev/null +++ b/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/TNAR/scripts/pg-debug-pod.yaml b/TNAR/scripts/pg-debug-pod.yaml new file mode 100644 index 0000000..652ed5b --- /dev/null +++ b/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/TNAR/scripts/read_pg_user_secret.py b/TNAR/scripts/read_pg_user_secret.py new file mode 100644 index 0000000..b0735f3 --- /dev/null +++ b/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/demo-managed-functions/README.md b/demo-managed-functions/README.md new file mode 100644 index 0000000..1fd1bf3 --- /dev/null +++ b/demo-managed-functions/README.md @@ -0,0 +1,194 @@ +# Примеры использования sless + +## Обзор платформы + +**sless** — система управления serverless-функциями на базе Kubernetes. Разработчик загружает код функции, платформа собирает из него Docker-образ, разворачивает его в кластере и предоставляет HTTP-эндпоинт для вызова. Всё описывается декларативно через Terraform. + +### Основные ресурсы провайдера + +| Ресурс | Назначение | +|---|---| +| `sless_function` | Описывает функцию: язык, точку входа, лимиты, переменные окружения. При создании загружает код и запускает его сборку в образ. Сама по себе недоступна снаружи — нужен триггер или задание. | +| `sless_trigger` | Публикует функцию: тип `http` создаёт публичный URL, тип `cron` — запуск по расписанию. | +| `sless_job` | Запускает функцию однократно и ожидает завершения. Используется для одноразовых операций: инициализация БД, миграции, пакетная обработка. | + +Стандартная связка для HTTP API: `sless_function` + `sless_trigger` с `type = "http"` — в результате функция доступна по URL вида `https://sless.kube5s.ru/fn//<имя-функции>`. + +--- + +## Требования + +- Terraform >= 1.0 +- JWT-токен для аутентификации в sless API +- Доступ к `https://sless.kube5s.ru` + +## Конфигурация провайдера + +Во всех примерах файл `main.tf` содержит блок провайдера. Токен передаётся через переменную, значение которой задаётся в `terraform.tfvars`: + +```hcl +provider "sless" { + endpoint = "https://sless.kube5s.ru" + token = var.token + nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" +} +``` + +Namespace функций вычисляется автоматически из JWT-токена: `sless-{sha256[:8]}`. + +> **Перед запуском любого примера** откройте файл `terraform.tfvars` в директории примера и впишите свой токен Nubes API: +> ```hcl +> token = "ваш токен Nubes API" +> ``` +> Токен выдаётся в личном кабинете Nubes. Файл `terraform.tfvars` добавлен в `.gitignore` — он не попадёт в репозиторий. + +--- + +## Примеры + +### `hello-node` — минимальный пример на Node.js + +Две независимые функции: HTTP-функция, возвращающая приветствие, и одноразовое задание, суммирующее набор чисел. Хорошая отправная точка для знакомства с платформой. + +```bash +cd hello-node +terraform init +terraform apply -auto-approve + +# Вызов HTTP-функции с передачей имени: +curl -s -X POST https://sless.kube5s.ru/fn//hello-http \ + -H 'Content-Type: application/json' -d '{"name":"World"}' + +# Результат задания: +terraform output job_message +``` + +--- + +### `hello-go` — минимальный пример на Go 1.23 + +Аналог `hello-node`, но на Go. Демонстрирует поддержку Go-рантайма: HTTP-функция и одноразовое задание. Код пользователя оформляется как пакет `handler` с функцией `Handle(event)`. + +```bash +cd hello-go +terraform init +terraform apply -auto-approve + +terraform output job_message +terraform output trigger_url +``` + +--- + +### `pg-list-python` — выборка данных из PostgreSQL (Python) + +Минимальный пример работы с базой данных: одна HTTP-функция читает список записей из таблицы PostgreSQL и возвращает их в JSON. Таблица с тестовыми данными создаётся автоматически при первом вызове. Нет заданий, нет инициализации — только функция и триггер. + +**Переменные:** + +| Переменная | Описание | Значение по умолчанию | +|---|---|---| +| `pg_dsn` | Строка подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` | + +```bash +cd pg-list-python +terraform init +terraform apply -auto-approve + +# URL функции выводится после применения: +terraform output catalog_url + +# Запрос к функции: +curl -s $(terraform output -raw catalog_url) +``` + +--- + +### `simple-python` — одноразовое задание передаёт данные в HTTP-функцию (Python) + +При `apply` выполняется задание, которое фиксирует текущее время. Результат передаётся в HTTP-функцию через переменные окружения и отображается при каждом запросе. + +```bash +cd simple-python +terraform init +terraform apply -auto-approve + +terraform output job_result +curl -s https://sless.kube5s.ru/fn//simple-py-time-display +``` + +--- + +### `simple-node` — то же самое на Node.js 20 + +```bash +cd simple-node +terraform init +terraform apply -auto-approve + +terraform output job_result +curl -s https://sless.kube5s.ru/fn//simple-node-time-display +``` + +--- + +### `notes-python` — CRUD API на Python с PostgreSQL + +Полноценное приложение: инициализация схемы базы данных через задания, CRUD-функция для работы с записями, отдельная функция для получения списка. + +**Переменные:** + +| Переменная | Описание | Значение по умолчанию | +|---|---|---| +| `pg_dsn` | Строка подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` | + +```bash +cd notes-python +terraform init +terraform apply -auto-approve + +# Статус инициализации базы данных: +terraform output db_init_table_status +terraform output db_init_index_status + +# Создать запись: +curl -s -X POST "$(terraform output -raw notes_url)/add?title=Hello&body=World" + +# Получить список записей: +curl -s $(terraform output -raw notes_list_url) + +# Обновить запись (id из предыдущего ответа): +curl -s -X POST "$(terraform output -raw notes_url)/update?id=1&title=Updated&body=New+body" + +# Удалить запись: +curl -s -X POST "$(terraform output -raw notes_url)/delete?id=1" +``` + +--- + +## Полезные команды + +```bash +# Посмотреть текущее состояние задеплоенных ресурсов: +terraform show + +# Принудительно пересобрать функцию (например, после изменения кода): +terraform apply -replace=sless_function.<имя> -auto-approve + +# Повторно запустить задание: увеличить значение run_id в .tf-файле, затем: +terraform apply -auto-approve + +# Удалить все ресурсы примера: +terraform destroy -auto-approve +``` + +## Структура примера + +``` +<пример>/ +├── main.tf — конфигурация провайдера +├── *.tf — ресурсы: функции, триггеры, задания +├── variables.tf — входные переменные +├── terraform.tfvars — значения переменных (не коммитится в git) +└── code/ — исходный код функций +``` diff --git a/demo-managed-functions/terraform.tfvars.example b/demo-managed-functions/terraform.tfvars.example index 983f6da..db09390 100644 --- a/demo-managed-functions/terraform.tfvars.example +++ b/demo-managed-functions/terraform.tfvars.example @@ -2,8 +2,8 @@ # Скопируй в terraform.tfvars и подставь актуальный токен. token = "PUT_TOKEN_HERE" -sless_endpoint = "http://sless-api.185.247.187.147.nip.io" -nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1" +sless_endpoint = "https://sless-api.kube5s.ru" +nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" pg_dsn = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable" rabbitmq_url = "amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/" diff --git a/demo-managed-functions/variables.tf b/demo-managed-functions/variables.tf index a00a728..6012dff 100644 --- a/demo-managed-functions/variables.tf +++ b/demo-managed-functions/variables.tf @@ -10,7 +10,7 @@ variable "token" { variable "sless_endpoint" { description = "Endpoint sless API" type = string - default = "https://sless-api.kube5s.ru" + default = "https://sless.kube5s.ru" } variable "nubes_endpoint" { diff --git a/hello-go/main.tf b/hello-go/main.tf index ac319ca..ef1580e 100644 --- a/hello-go/main.tf +++ b/hello-go/main.tf @@ -11,7 +11,7 @@ terraform { } provider "sless" { - endpoint = "https://sless-api.kube5s.ru" + endpoint = "https://sless.kube5s.ru" token = var.token nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" } diff --git a/hello-node/main.tf b/hello-node/main.tf index ecc41af..09f8a39 100644 --- a/hello-node/main.tf +++ b/hello-node/main.tf @@ -17,7 +17,7 @@ terraform { } provider "sless" { - endpoint = "https://sless-api.kube5s.ru" + endpoint = "https://sless.kube5s.ru" token = var.token nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" } diff --git a/notes-python/main.tf b/notes-python/main.tf index 02a6e4a..21762bb 100644 --- a/notes-python/main.tf +++ b/notes-python/main.tf @@ -21,7 +21,7 @@ terraform { # sless провайдер подключается к API кластера. provider "sless" { - endpoint = "https://sless-api.kube5s.ru" + endpoint = "https://sless.kube5s.ru" token = var.token nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" } diff --git a/pg-list-python/main.tf b/pg-list-python/main.tf index 028d81c..871034b 100644 --- a/pg-list-python/main.tf +++ b/pg-list-python/main.tf @@ -11,7 +11,7 @@ terraform { } provider "sless" { - endpoint = "https://sless-api.kube5s.ru" + endpoint = "https://sless.kube5s.ru" token = var.token nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" } diff --git a/simple-node/main.tf b/simple-node/main.tf index 2376334..af95879 100644 --- a/simple-node/main.tf +++ b/simple-node/main.tf @@ -25,7 +25,7 @@ terraform { } provider "sless" { - endpoint = "https://sless-api.kube5s.ru" + endpoint = "https://sless.kube5s.ru" token = var.token nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" } diff --git a/simple-python/main.tf b/simple-python/main.tf index 7485ca6..e55c11a 100644 --- a/simple-python/main.tf +++ b/simple-python/main.tf @@ -24,7 +24,7 @@ terraform { } provider "sless" { - endpoint = "https://sless-api.kube5s.ru" + endpoint = "https://sless.kube5s.ru" token = var.token nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" }