From 9bc91841c8152ecf2897f339c3b23ea0ecf0ab3b Mon Sep 17 00:00:00 2001 From: Naeel Date: Wed, 18 Mar 2026 11:03:58 +0300 Subject: [PATCH] feat: NodeJS pg-info function; funcs endpoint: filter + created_at/last_built_at; operator v0.1.32 --- deployments/k8s/operator.yaml | 2 +- .../POSTGRES/code/funcs-list/funcs_list.py | 136 ++++++++++++------ examples/POSTGRES/code/pg-info/package.json | 8 ++ examples/POSTGRES/code/pg-info/pg_info.js | 43 ++++++ examples/POSTGRES/funcs_list.py | 129 +++++++++++++++++ examples/POSTGRES/resources.tf | 43 +++++- internal/api/handler/functions.go | 61 +++++--- 7 files changed, 352 insertions(+), 70 deletions(-) create mode 100644 examples/POSTGRES/code/pg-info/package.json create mode 100644 examples/POSTGRES/code/pg-info/pg_info.js create mode 100644 examples/POSTGRES/funcs_list.py diff --git a/deployments/k8s/operator.yaml b/deployments/k8s/operator.yaml index 85d541f..a48c157 100644 --- a/deployments/k8s/operator.yaml +++ b/deployments/k8s/operator.yaml @@ -72,7 +72,7 @@ spec: containers: - name: operator # При обновлении версии оператора — менять тег здесь (не latest!) - image: naeel/sless-operator:v0.1.31 + image: naeel/sless-operator:v0.1.32 # Always — чтобы всегда тянуть по точному тегу (не кешировать старый) imagePullPolicy: Always ports: diff --git a/examples/POSTGRES/code/funcs-list/funcs_list.py b/examples/POSTGRES/code/funcs-list/funcs_list.py index 350ede6..bc17fdc 100644 --- a/examples/POSTGRES/code/funcs-list/funcs_list.py +++ b/examples/POSTGRES/code/funcs-list/funcs_list.py @@ -1,30 +1,53 @@ -# 2026-03-18 -# funcs_list.py — HTTP-функция, возвращает список всех функций пользователя. -# Вызывает внутренний REST API оператора (http://sless-operator.sless.svc.cluster.local:9090). -# Объединяет данные функций и их триггеров в один ответ. +# 2026-03-18 (обновлено: фильтрация SLESS_EXCLUDE, читаемый вывод через "#"-ключ) +# funcs_list.py — HTTP-функция: список всех пользовательских функций с их статусами. +# Вызывает внутренний REST API оператора (ClusterIP, без TLS). +# Объединяет данные функций и триггеров в один ответ; скрывает служебные функции. # -# Env vars (обязательные): -# SLESS_API_URL — URL оператора внутри кластера -# SLESS_NAMESPACE — namespace пользователя (sless-{hex16}) -# SLESS_TOKEN — JWT токен для авторизации в /v1/ API +# 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" # -# Поля в ответе: -# name, runtime, phase, image_ref, message — из /v1/namespaces/{ns}/functions -# triggers[].type/enabled/active/url — из /v1/namespaces/{ns}/triggers -# -# Что ПОКА не возвращается (фиксировано оператором): -# created_at — metav1.CreationTimestamp, не включён в fnToResponse (нужно v0.1.32+) -# last_built_at — FunctionStatus.LastBuiltAt, не включён в fnToResponse (нужно v0.1.32+) +# Формат вывода: JSON-объект, где каждая функция содержит поле "#" — краткий комментарий. +# При pretty-print (python3 -m json.tool) выглядит как читаемый список с аннотациями. import os -import json 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("/") + api_url = os.environ["SLESS_API_URL"].rstrip("/") namespace = os.environ["SLESS_NAMESPACE"] - token = os.environ["SLESS_TOKEN"] + 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( @@ -33,7 +56,6 @@ def list_all(event): timeout=10, ) fns_resp.raise_for_status() - functions = fns_resp.json() trs_resp = requests.get( f"{api_url}/v1/namespaces/{namespace}/triggers", @@ -41,43 +63,67 @@ def list_all(event): timeout=10, ) trs_resp.raise_for_status() - triggers = trs_resp.json() - # Индексируем триггеры по имени функции (поле "function" = functionRef). + # Индекс триггеров по имени функции triggers_by_fn = {} - for tr in triggers: + 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 functions: + for fn in fns_resp.json(): name = fn["name"] - fn_triggers = [ - { - "name": tr.get("name"), - "type": tr.get("type"), - "enabled": tr.get("enabled", True), - "active": tr.get("active", False), - "url": tr.get("url", ""), - "schedule": tr.get("schedule", ""), - } - for tr in triggers_by_fn.get(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, []) + ) - result.append({ - "name": name, + entry = { + # "#" — первый ключ: служит визуальным комментарием при pretty-print + "#": _short_comment(fn, http_triggers, cron_triggers), + "name": name, "runtime": fn.get("runtime"), - "phase": fn.get("phase"), - # active = у функции есть хотя бы один включённый и активный триггер - "active": any(t["enabled"] and t["active"] for t in fn_triggers), - "image_ref": fn.get("image_ref", ""), - "message": fn.get("message", ""), - "triggers": fn_triggers, - # TODO: добавить created_at и last_built_at после обновления оператора до v0.1.32+ - }) + "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 {"functions": result, "count": len(result)} + return { + "namespace": namespace, + "count": len(result), + "functions": result, + } + diff --git a/examples/POSTGRES/code/pg-info/package.json b/examples/POSTGRES/code/pg-info/package.json new file mode 100644 index 0000000..5e6394d --- /dev/null +++ b/examples/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/examples/POSTGRES/code/pg-info/pg_info.js b/examples/POSTGRES/code/pg-info/pg_info.js new file mode 100644 index 0000000..e08df17 --- /dev/null +++ b/examples/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/examples/POSTGRES/funcs_list.py b/examples/POSTGRES/funcs_list.py new file mode 100644 index 0000000..bc17fdc --- /dev/null +++ b/examples/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/examples/POSTGRES/resources.tf b/examples/POSTGRES/resources.tf index cacc410..3605ef0 100644 --- a/examples/POSTGRES/resources.tf +++ b/examples/POSTGRES/resources.tf @@ -95,6 +95,37 @@ resource "sless_job" "postgres_table_init_job" { 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 и возвращает JSON. # Использует те же credentials что и sql-runner. # Доступна по URL: https://sless.kube5s.ru/fn//pg-table-reader @@ -149,10 +180,14 @@ resource "sless_function" "funcs_list" { timeout_sec = 15 env_vars = { - # Внутренний ClusterIP сервис оператора — без TLS, без DNS-overhead. - SLESS_API_URL = "http://sless-operator.sless.svc.cluster.local:9090" - SLESS_NAMESPACE = local.user_namespace - SLESS_TOKEN = var.api_token + # Внутренний ClusterIP сервис оператора — без TLS, без DNS-overhead + SLESS_API_URL = "http://sless-operator.sless.svc.cluster.local:9090" + SLESS_NAMESPACE = local.user_namespace + SLESS_TOKEN = var.api_token + # Публичный домен: terraform state может хранить старый sless-api.kube5s.ru в URLs + SLESS_EXTERNAL_URL = "https://sless.kube5s.ru" + # Служебные функции — не показывать в листинге + SLESS_EXCLUDE = "funcs,event-writer,event-monitor,event-cleaner" } source_dir = "${path.module}/code/funcs-list" diff --git a/internal/api/handler/functions.go b/internal/api/handler/functions.go index df7a4b3..c1770de 100644 --- a/internal/api/handler/functions.go +++ b/internal/api/handler/functions.go @@ -1,4 +1,4 @@ -// Изменено: 2026-03-11 +// Изменено: 2026-03-18 (добавлены created_at, last_built_at в functionResponse и fnToResponse) // functions.go — CRUD handlers для Function CRD. // Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function. // Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name} @@ -30,23 +30,29 @@ type functionRequest struct { // functionResponse — ответ при чтении функции. type functionResponse struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - Runtime string `json:"runtime"` - Entrypoint string `json:"entrypoint"` - MemoryMB int32 `json:"memory_mb"` - TimeoutSec int32 `json:"timeout_sec"` - Env map[string]string `json:"env_vars"` - S3Bucket string `json:"s3_bucket"` - S3Key string `json:"s3_key"` - Phase slessv1alpha1.FunctionPhase `json:"phase"` - ImageRef string `json:"image_ref"` - Message string `json:"message,omitempty"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Runtime string `json:"runtime"` + Entrypoint string `json:"entrypoint"` + MemoryMB int32 `json:"memory_mb"` + TimeoutSec int32 `json:"timeout_sec"` + Env map[string]string `json:"env_vars"` + S3Bucket string `json:"s3_bucket"` + S3Key string `json:"s3_key"` + Phase slessv1alpha1.FunctionPhase `json:"phase"` + ImageRef string `json:"image_ref"` + Message string `json:"message,omitempty"` + // CreatedAt — время создания CRD объекта (metadata.creationTimestamp). + // Пустое значение = "0001-01-01T00:00:00Z" сериализуется в "", опускаем через omitempty. + CreatedAt string `json:"created_at,omitempty"` + // LastBuiltAt — время последней успешной сборки образа (status.lastBuiltAt). + // nil если сборки ещё не было. + LastBuiltAt string `json:"last_built_at,omitempty"` } // fnToResponse конвертирует CRD в ответ API. func fnToResponse(fn *slessv1alpha1.Function) functionResponse { - return functionResponse{ + resp := functionResponse{ Name: fn.Name, Namespace: fn.Namespace, Runtime: fn.Spec.Runtime, @@ -60,6 +66,14 @@ func fnToResponse(fn *slessv1alpha1.Function) functionResponse { ImageRef: fn.Status.ImageRef, Message: fn.Status.Message, } + // creationTimestamp — всегда заполнен k8s, но zero value опускаем. + if !fn.CreationTimestamp.IsZero() { + resp.CreatedAt = fn.CreationTimestamp.UTC().Format("2006-01-02 15:04:05 UTC") + } + if fn.Status.LastBuiltAt != nil && !fn.Status.LastBuiltAt.IsZero() { + resp.LastBuiltAt = fn.Status.LastBuiltAt.UTC().Format("2006-01-02 15:04:05 UTC") + } + return resp } // ListFunctions — GET /v1/namespaces/{namespace}/functions @@ -115,13 +129,20 @@ func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) { } if err := h.K8s.Create(r.Context(), fn); err != nil { if errors.IsAlreadyExists(err) { - // Если существующая функция в статусе Failed (build провалился, terraform не - // добавил её в state) — удаляем её и пересоздаём, иначе клиент получит 409 навсегда. + // IsAlreadyExists может прийти из кеша controller-runtime (split-brain): + // объект удалён из etcd, но кеш informer ещё южив. Делаем uncached Get: + // если реально NotFound — кеш устарел, пересоздаём. + // если существует и фаза Failed — тоже пересоздаём (build провалился, терраформ не добавил в state). + // если существует и фаза Ready/Building — возвращаем 409 (функция реально есть). existing := &slessv1alpha1.Function{} - if getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing); getErr == nil && - existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed { - _ = h.K8s.Delete(r.Context(), existing) - // Создаём заново с теми же параметрами + getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing) + shouldRecreate := errors.IsNotFound(getErr) || + (getErr == nil && existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed) + if shouldRecreate { + if getErr == nil { + _ = h.K8s.Delete(r.Context(), existing) + } + // Сбрасываем ResourceVersion — при split-brain etcd считает объект новым fn.ResourceVersion = "" if createErr := h.K8s.Create(r.Context(), fn); createErr != nil { writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))