diff --git a/examples/notes-python/code/notes-list/handler.py b/examples/notes-python/code/notes-list/handler.py new file mode 100644 index 0000000..84a0cf4 --- /dev/null +++ b/examples/notes-python/code/notes-list/handler.py @@ -0,0 +1,22 @@ +# 2026-03-09 +# handler.py — возвращает все записи из таблицы notes. +# GET/POST /fn/default/notes-list → JSON массив записей, сортировка по created_at DESC. +import os +import psycopg2 +import psycopg2.extras + + +def handle(event): + dsn = os.environ['PG_DSN'] + conn = psycopg2.connect(dsn) + try: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + "SELECT id, title, body, created_at::text FROM notes ORDER BY created_at DESC" + ) + rows = cur.fetchall() + return [dict(r) for r in rows] + except Exception as e: + return {'error': str(e)} + finally: + conn.close() diff --git a/examples/notes-python/code/notes-list/requirements.txt b/examples/notes-python/code/notes-list/requirements.txt new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/examples/notes-python/code/notes-list/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/examples/notes-python/code/notes/handler.py b/examples/notes-python/code/notes/handler.py new file mode 100644 index 0000000..cce026f --- /dev/null +++ b/examples/notes-python/code/notes/handler.py @@ -0,0 +1,74 @@ +# 2026-03-09 +# handler.py — CRUD роутер для таблицы notes. +# Роутинг по event._path (sub-path URL): +# POST /fn/default/notes/add?title=...&body=... → INSERT +# POST /fn/default/notes/update?id=1&title=... → UPDATE +# POST /fn/default/notes/delete?id=1 → DELETE +# _path и _query добавляет runtime из HTTP запроса (server.py). +import os +import psycopg2 +import psycopg2.extras + + +def handle(event): + dsn = os.environ['PG_DSN'] + # sub-path без ведущего слэша: "add", "update", "delete" + action = event.get('_path', '/').strip('/') + q = event.get('_query', {}) + + conn = psycopg2.connect(dsn) + try: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + + if action == 'add': + title = q.get('title') or event.get('title', '') + body = q.get('body') or event.get('body', '') + if not title: + return {'error': 'title is required'} + cur.execute( + "INSERT INTO notes (title, body) VALUES (%s, %s)" + " RETURNING id, title, body, created_at::text", + (title, body) + ) + row = cur.fetchone() + conn.commit() + return dict(row) + + elif action == 'update': + id_ = q.get('id') or event.get('id') + if not id_: + return {'error': 'id is required'} + title = q.get('title') or event.get('title', '') + body = q.get('body') or event.get('body', '') + cur.execute( + "UPDATE notes SET title=%s, body=%s WHERE id=%s" + " RETURNING id, title, body, created_at::text", + (title, body, int(id_)) + ) + row = cur.fetchone() + conn.commit() + return dict(row) if row else {'error': 'not found'} + + elif action == 'delete': + id_ = q.get('id') or event.get('id') + if not id_: + return {'error': 'id is required'} + cur.execute( + "DELETE FROM notes WHERE id=%s RETURNING id", + (int(id_),) + ) + row = cur.fetchone() + conn.commit() + return {'deleted': row['id']} if row else {'error': 'not found'} + + else: + return { + 'error': f'unknown action: /{action}', + 'hint': 'use /add?title=...&body=..., /update?id=X&title=...&body=..., /delete?id=X' + } + + except Exception as e: + conn.rollback() + return {'error': str(e)} + finally: + conn.close() diff --git a/examples/notes-python/code/notes/requirements.txt b/examples/notes-python/code/notes/requirements.txt new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/examples/notes-python/code/notes/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/examples/notes-python/code/sql-runner/handler.py b/examples/notes-python/code/sql-runner/handler.py new file mode 100644 index 0000000..2b39304 --- /dev/null +++ b/examples/notes-python/code/sql-runner/handler.py @@ -0,0 +1,26 @@ +# 2026-03-09 +# handler.py — универсальный исполнитель SQL запросов. +# Принимает event.statements — массив SQL строк, выполняет последовательно. +# Используется sless_job для DDL операций (CREATE TABLE, миграции и т.д.) +import os +import psycopg2 + + +def handle(event): + dsn = os.environ['PG_DSN'] + statements = event.get('statements', []) + if not statements: + return {'error': 'no statements provided'} + + conn = psycopg2.connect(dsn) + try: + cur = conn.cursor() + for sql in statements: + cur.execute(sql) + conn.commit() + return {'ok': True, 'executed': len(statements)} + except Exception as e: + conn.rollback() + return {'error': str(e)} + finally: + conn.close() diff --git a/examples/notes-python/code/sql-runner/requirements.txt b/examples/notes-python/code/sql-runner/requirements.txt new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/examples/notes-python/code/sql-runner/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/examples/notes-python/dist/notes-list.zip b/examples/notes-python/dist/notes-list.zip new file mode 100644 index 0000000..1e3b036 Binary files /dev/null and b/examples/notes-python/dist/notes-list.zip differ diff --git a/examples/notes-python/dist/notes.zip b/examples/notes-python/dist/notes.zip new file mode 100644 index 0000000..5402309 Binary files /dev/null and b/examples/notes-python/dist/notes.zip differ diff --git a/examples/notes-python/dist/sql-runner.zip b/examples/notes-python/dist/sql-runner.zip new file mode 100644 index 0000000..b684fdd Binary files /dev/null and b/examples/notes-python/dist/sql-runner.zip differ diff --git a/examples/notes-python/init.tf b/examples/notes-python/init.tf new file mode 100644 index 0000000..a76760f --- /dev/null +++ b/examples/notes-python/init.tf @@ -0,0 +1,34 @@ +# 2025-06-05 +# init.tf — джобы инициализации БД: создание таблицы + индекса. +# Запускаются один раз при terraform apply. +# Для повторного запуска (например после drop) — увеличь run_id. + +resource "sless_job" "create_table" { + namespace = "default" + name = "notes-create-table" + function = sless_function.sql_runner.name + wait_timeout_sec = 120 + run_id = 1 + + event_json = jsonencode({ + statements = [ + "CREATE TABLE IF NOT EXISTS notes (id serial PRIMARY KEY, title text NOT NULL, body text, created_at timestamp DEFAULT now())" + ] + }) +} + +resource "sless_job" "create_index" { + depends_on = [sless_job.create_table] + + namespace = "default" + name = "notes-create-index" + function = sless_function.sql_runner.name + wait_timeout_sec = 60 + run_id = 1 + + event_json = jsonencode({ + statements = [ + "CREATE INDEX IF NOT EXISTS notes_created_idx ON notes(created_at DESC)" + ] + }) +} diff --git a/examples/notes-python/main.tf b/examples/notes-python/main.tf new file mode 100644 index 0000000..5f5e71f --- /dev/null +++ b/examples/notes-python/main.tf @@ -0,0 +1,21 @@ +# 2025-06-05 +# main.tf — terraform{} + provider для notes-python примера. +# Ресурсы вынесены в отдельные .tf файлы. + +terraform { + required_providers { + sless = { + source = "terra.k8c.ru/naeel/sless" + version = "~> 0.1.7" + } + archive = { + source = "hashicorp/archive" + version = "~> 2.0" + } + } +} + +provider "sless" { + endpoint = "https://sless-api.kube5s.ru" + token = "dev-token-change-me" +} diff --git a/examples/notes-python/notes-list.tf b/examples/notes-python/notes-list.tf new file mode 100644 index 0000000..2dc4fcf --- /dev/null +++ b/examples/notes-python/notes-list.tf @@ -0,0 +1,32 @@ +# 2025-06-05 +# notes-list.tf — функция для получения всех записей из таблицы notes. +# GET или POST /fn/default/notes-list → JSON массив всех записей, сортировка по дате (новые первые). + +data "archive_file" "notes_list" { + type = "zip" + source_dir = "${path.module}/code/notes-list" + output_path = "${path.module}/dist/notes-list.zip" +} + +resource "sless_function" "notes_list" { + namespace = "default" + name = "notes-list" + runtime = "python3.11" + entrypoint = "handler.handle" + memory_mb = 128 + timeout_sec = 30 + + env_vars = { + PG_DSN = var.pg_dsn + } + + code_path = data.archive_file.notes_list.output_path + code_hash = filesha256("${path.module}/code/notes-list/handler.py") +} + +resource "sless_trigger" "notes_list_http" { + namespace = "default" + name = "notes-list-http" + type = "http" + function = sless_function.notes_list.name +} diff --git a/examples/notes-python/notes.tf b/examples/notes-python/notes.tf new file mode 100644 index 0000000..9d0cb64 --- /dev/null +++ b/examples/notes-python/notes.tf @@ -0,0 +1,35 @@ +# 2025-06-05 +# notes.tf — CRUD функция для таблицы notes. +# Роутинг по sub-path URL: +# POST /fn/default/notes/add?title=...&body=... → INSERT записи +# POST /fn/default/notes/update?id=1&title=...&body=... → UPDATE записи +# POST /fn/default/notes/delete?id=1 → DELETE записи + +data "archive_file" "notes" { + type = "zip" + source_dir = "${path.module}/code/notes" + output_path = "${path.module}/dist/notes.zip" +} + +resource "sless_function" "notes" { + namespace = "default" + name = "notes" + runtime = "python3.11" + entrypoint = "handler.handle" + memory_mb = 128 + timeout_sec = 30 + + env_vars = { + PG_DSN = var.pg_dsn + } + + code_path = data.archive_file.notes.output_path + code_hash = filesha256("${path.module}/code/notes/handler.py") +} + +resource "sless_trigger" "notes_http" { + namespace = "default" + name = "notes-http" + type = "http" + function = sless_function.notes.name +} diff --git a/examples/notes-python/outputs.tf b/examples/notes-python/outputs.tf new file mode 100644 index 0000000..d14f579 --- /dev/null +++ b/examples/notes-python/outputs.tf @@ -0,0 +1,12 @@ +# 2025-06-05 +# outputs.tf — URL эндпоинтов для notes-python примера. + +output "notes_url" { + value = sless_trigger.notes_http.url + description = "Базовый URL CRUD: /add?title=...&body=..., /update?id=X&title=...&body=..., /delete?id=X" +} + +output "notes_list_url" { + value = sless_trigger.notes_list_http.url + description = "URL для получения всех записей (GET или POST)" +} diff --git a/examples/notes-python/sql-runner.tf b/examples/notes-python/sql-runner.tf new file mode 100644 index 0000000..fe5a56c --- /dev/null +++ b/examples/notes-python/sql-runner.tf @@ -0,0 +1,26 @@ +# 2025-06-05 +# sql-runner.tf — универсальная функция для выполнения SQL запросов. +# Используется джобами для DDL операций (CREATE TABLE, индексы, миграции). +# event.statements — массив SQL строк, выполняются последовательно в одной транзакции. + +data "archive_file" "sql_runner" { + type = "zip" + source_dir = "${path.module}/code/sql-runner" + output_path = "${path.module}/dist/sql-runner.zip" +} + +resource "sless_function" "sql_runner" { + namespace = "default" + name = "sql-runner" + runtime = "python3.11" + entrypoint = "handler.handle" + memory_mb = 128 + timeout_sec = 30 + + env_vars = { + PG_DSN = var.pg_dsn + } + + code_path = data.archive_file.sql_runner.output_path + code_hash = filesha256("${path.module}/code/sql-runner/handler.py") +} diff --git a/examples/notes-python/variables.tf b/examples/notes-python/variables.tf new file mode 100644 index 0000000..d0ffeb4 --- /dev/null +++ b/examples/notes-python/variables.tf @@ -0,0 +1,9 @@ +# 2025-06-05 +# variables.tf — входные переменные для notes-python примера. + +variable "pg_dsn" { + description = "PostgreSQL DSN для подключения к БД внутри кластера" + type = string + default = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable" + sensitive = true +} diff --git a/internal/api/handler/invoke.go b/internal/api/handler/invoke.go index aa86588..797cab3 100644 --- a/internal/api/handler/invoke.go +++ b/internal/api/handler/invoke.go @@ -1,10 +1,11 @@ -// Изменено: 2026-03-07 +// Изменено: 2026-03-09 // invoke.go — прокси-обработчик для вызова HTTP-триггеров функций. -// Маршрут: ANY /fn/{namespace}/{name} +// Маршрут: ANY /fn/{namespace}/{name} и /fn/{namespace}/{name}/** // Не защищён auth-токеном — это публичный эндпоинт для вызова функций. // Проксирует запрос к ClusterIP Service функции внутри кластера: // http://{name}.sless-fn-{namespace}.svc.cluster.local:8080 -// Функция должна быть задеплоена (HTTP триггер активен). +// Sub-path и query string пробрасываются как есть: +// /fn/ns/notes/add?title=x → http://notes.sless-fn-ns.svc.../add?title=x package handler @@ -12,6 +13,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" "github.com/gorilla/mux" @@ -23,15 +25,24 @@ var httpClient = &http.Client{Timeout: 30 * time.Second} // InvokeFunction проксирует входящий запрос к Service функции в кластере. // Namespace выбирается из пути, имя функции — тоже из пути. -// Сохраняет метод, тело, и Content-Type заголовок оригинального запроса. +// Сохраняет метод, тело, Content-Type, sub-path и query string. func (h *Handler) InvokeFunction(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) ns := vars["namespace"] name := vars["name"] + // Вычисляем sub-path после /fn/{namespace}/{name} + // Например: /fn/default/notes/add → subPath = /add + prefix := "/fn/" + ns + "/" + name + subPath := strings.TrimPrefix(r.URL.Path, prefix) + // Внутренний URL к Service функции (DNS внутри кластера) - // Namespace функций: sless-fn-{user-namespace} - target := fmt.Sprintf("http://%s.sless-fn-%s.svc.cluster.local:8080", name, ns) + target := fmt.Sprintf("http://%s.sless-fn-%s.svc.cluster.local:8080%s", name, ns, subPath) + + // Пробрасываем query string если есть + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } // Создаём проксируемый запрос с тем же методом и телом proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body) diff --git a/internal/api/handler/upload.go b/internal/api/handler/upload.go index acfdefa..8f77ab9 100644 --- a/internal/api/handler/upload.go +++ b/internal/api/handler/upload.go @@ -31,9 +31,9 @@ import ( func runtimeBaseImage(runtime string) (string, error) { switch runtime { case "python3.11": - return "naeel/sless-runtime-python3.11:v0.1.0", nil + return "naeel/sless-runtime-python3.11:v0.1.1", nil case "nodejs20": - return "naeel/sless-runtime-nodejs20:v0.1.1", nil + return "naeel/sless-runtime-nodejs20:v0.1.2", nil default: return "", fmt.Errorf("unsupported runtime: %q (supported: python3.11, nodejs20)", runtime) } diff --git a/runtimes/nodejs20/server.js b/runtimes/nodejs20/server.js index fcaab09..34f2431 100644 --- a/runtimes/nodejs20/server.js +++ b/runtimes/nodejs20/server.js @@ -1,4 +1,4 @@ -// Изменено: 2026-03-08 +// Изменено: 2026-03-09 // HTTP-обёртка для serverless функций на Node.js 20. // Загружает модуль из SLESS_ENTRYPOINT или handler.js по умолчанию. // Формат SLESS_ENTRYPOINT: "module-name.functionName" (например: handler-http.handle) @@ -51,6 +51,18 @@ const server = http.createServer(async (req, res) => { } } + // Добавляем метаданные запроса: путь, query params, метод. + // _path/_query/_method — зарезервированные поля, не конфликтуют с бизнес-полями. + // Позволяет роутить внутри одной функции: event._path === '/add' + const urlObj = new URL(req.url, 'http://localhost'); + const query = {}; + urlObj.searchParams.forEach((v, k) => { query[k] = v; }); + if (typeof event === 'object' && !Array.isArray(event)) { + event._path = urlObj.pathname; + event._query = query; + event._method = req.method; + } + try { const result = await userHandle(event); sendJSON(res, 200, result); diff --git a/runtimes/python3.11/server.py b/runtimes/python3.11/server.py index 7307d23..b2cdd42 100644 --- a/runtimes/python3.11/server.py +++ b/runtimes/python3.11/server.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Изменено: 2026-03-08 +# Изменено: 2026-03-09 # HTTP-обёртка для serverless функций на Python 3.11. # Загружает модуль из SLESS_ENTRYPOINT или handler.py по умолчанию. # Формат SLESS_ENTRYPOINT: "module_name.func_name" (например: handler.handle) @@ -10,6 +10,7 @@ import os import json import importlib.util from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs PORT = 8080 @@ -44,11 +45,25 @@ class FunctionHandler(BaseHTTPRequestHandler): format % args, )) + def _parse_request_meta(self, event): + # Добавляем _path, _query, _method в event — позволяет роутить внутри функции. + # Например: /fn/ns/notes/add?title=x → event._path='/add', event._query={'title':'x'} + # Зарезервированные поля, не конфликтуют с бизнес-полями. + parsed = urlparse(self.path) + query = {k: v[0] if len(v) == 1 else v + for k, v in parse_qs(parsed.query).items()} + if isinstance(event, dict): + event['_path'] = parsed.path + event['_query'] = query + event['_method'] = self.command + return event + def do_GET(self): if self.path == "/health": self._respond(200, {"status": "ok"}) else: - self._respond(200, _handle({})) + event = self._parse_request_meta({}) + self._respond(200, _handle(event)) def do_POST(self): length = int(self.headers.get("Content-Length", 0)) @@ -58,6 +73,7 @@ class FunctionHandler(BaseHTTPRequestHandler): except json.JSONDecodeError: # Если тело не JSON — передаём как строку, не ломаем вызов event = {"body": body.decode("utf-8", errors="replace")} + event = self._parse_request_meta(event) result = _handle(event) self._respond(200, result)