feat: NodeJS pg-info function; funcs endpoint: filter + created_at/last_built_at; operator v0.1.32
This commit is contained in:
@@ -72,7 +72,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: operator
|
- name: operator
|
||||||
# При обновлении версии оператора — менять тег здесь (не latest!)
|
# При обновлении версии оператора — менять тег здесь (не latest!)
|
||||||
image: naeel/sless-operator:v0.1.31
|
image: naeel/sless-operator:v0.1.32
|
||||||
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,30 +1,53 @@
|
|||||||
# 2026-03-18
|
# 2026-03-18 (обновлено: фильтрация SLESS_EXCLUDE, читаемый вывод через "#"-ключ)
|
||||||
# funcs_list.py — HTTP-функция, возвращает список всех функций пользователя.
|
# funcs_list.py — HTTP-функция: список всех пользовательских функций с их статусами.
|
||||||
# Вызывает внутренний REST API оператора (http://sless-operator.sless.svc.cluster.local:9090).
|
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
|
||||||
# Объединяет данные функций и их триггеров в один ответ.
|
# Объединяет данные функций и триггеров в один ответ; скрывает служебные функции.
|
||||||
#
|
#
|
||||||
# Env vars (обязательные):
|
# Env vars:
|
||||||
# SLESS_API_URL — URL оператора внутри кластера
|
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
|
||||||
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
|
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
|
||||||
# SLESS_TOKEN — JWT токен для авторизации в /v1/ API
|
# 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-объект, где каждая функция содержит поле "#" — краткий комментарий.
|
||||||
# name, runtime, phase, image_ref, message — из /v1/namespaces/{ns}/functions
|
# При pretty-print (python3 -m json.tool) выглядит как читаемый список с аннотациями.
|
||||||
# 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+)
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
import requests
|
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):
|
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"]
|
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}"}
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
fns_resp = requests.get(
|
fns_resp = requests.get(
|
||||||
@@ -33,7 +56,6 @@ def list_all(event):
|
|||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
fns_resp.raise_for_status()
|
fns_resp.raise_for_status()
|
||||||
functions = fns_resp.json()
|
|
||||||
|
|
||||||
trs_resp = requests.get(
|
trs_resp = requests.get(
|
||||||
f"{api_url}/v1/namespaces/{namespace}/triggers",
|
f"{api_url}/v1/namespaces/{namespace}/triggers",
|
||||||
@@ -41,43 +63,67 @@ def list_all(event):
|
|||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
trs_resp.raise_for_status()
|
trs_resp.raise_for_status()
|
||||||
triggers = trs_resp.json()
|
|
||||||
|
|
||||||
# Индексируем триггеры по имени функции (поле "function" = functionRef).
|
# Индекс триггеров по имени функции
|
||||||
triggers_by_fn = {}
|
triggers_by_fn = {}
|
||||||
for tr in triggers:
|
for tr in trs_resp.json():
|
||||||
fn_name = tr.get("function") or tr.get("functionRef")
|
fn_name = tr.get("function") or tr.get("functionRef")
|
||||||
if fn_name:
|
if fn_name:
|
||||||
triggers_by_fn.setdefault(fn_name, []).append(tr)
|
triggers_by_fn.setdefault(fn_name, []).append(tr)
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for fn in functions:
|
for fn in fns_resp.json():
|
||||||
name = fn["name"]
|
name = fn["name"]
|
||||||
fn_triggers = [
|
if name in exclude:
|
||||||
{
|
continue
|
||||||
"name": tr.get("name"),
|
|
||||||
"type": tr.get("type"),
|
http_triggers = [
|
||||||
"enabled": tr.get("enabled", True),
|
t for t in triggers_by_fn.get(name, []) if t.get("type") == "http"
|
||||||
"active": tr.get("active", False),
|
|
||||||
"url": tr.get("url", ""),
|
|
||||||
"schedule": tr.get("schedule", ""),
|
|
||||||
}
|
|
||||||
for tr in triggers_by_fn.get(name, [])
|
|
||||||
]
|
]
|
||||||
|
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({
|
entry = {
|
||||||
"name": name,
|
# "#" — первый ключ: служит визуальным комментарием при pretty-print
|
||||||
|
"#": _short_comment(fn, http_triggers, cron_triggers),
|
||||||
|
"name": name,
|
||||||
"runtime": fn.get("runtime"),
|
"runtime": fn.get("runtime"),
|
||||||
"phase": fn.get("phase"),
|
"phase": fn.get("phase"),
|
||||||
# active = у функции есть хотя бы один включённый и активный триггер
|
"active": is_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+
|
|
||||||
})
|
|
||||||
|
|
||||||
# Сортируем: сначала активные, потом по имени
|
# 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"]))
|
result.sort(key=lambda f: (not f["active"], f["name"]))
|
||||||
|
|
||||||
return {"functions": result, "count": len(result)}
|
return {
|
||||||
|
"namespace": namespace,
|
||||||
|
"count": len(result),
|
||||||
|
"functions": result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "pg-info",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "sless nodejs20 function: pg version + table info",
|
||||||
|
"dependencies": {
|
||||||
|
"pg": "8.11.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -95,6 +95,37 @@ 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/<namespace>/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.
|
# HTTP-функция читает строки из terraform_demo_table и возвращает JSON.
|
||||||
# Использует те же credentials что и sql-runner.
|
# Использует те же credentials что и sql-runner.
|
||||||
# Доступна по URL: https://sless.kube5s.ru/fn/<namespace>/pg-table-reader
|
# Доступна по URL: https://sless.kube5s.ru/fn/<namespace>/pg-table-reader
|
||||||
@@ -149,10 +180,14 @@ resource "sless_function" "funcs_list" {
|
|||||||
timeout_sec = 15
|
timeout_sec = 15
|
||||||
|
|
||||||
env_vars = {
|
env_vars = {
|
||||||
# Внутренний ClusterIP сервис оператора — без TLS, без DNS-overhead.
|
# Внутренний ClusterIP сервис оператора — без TLS, без DNS-overhead
|
||||||
SLESS_API_URL = "http://sless-operator.sless.svc.cluster.local:9090"
|
SLESS_API_URL = "http://sless-operator.sless.svc.cluster.local:9090"
|
||||||
SLESS_NAMESPACE = local.user_namespace
|
SLESS_NAMESPACE = local.user_namespace
|
||||||
SLESS_TOKEN = var.api_token
|
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"
|
source_dir = "${path.module}/code/funcs-list"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Изменено: 2026-03-11
|
// Изменено: 2026-03-18 (добавлены created_at, last_built_at в functionResponse и fnToResponse)
|
||||||
// functions.go — CRUD handlers для Function CRD.
|
// functions.go — CRUD handlers для Function CRD.
|
||||||
// Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function.
|
// Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function.
|
||||||
// Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name}
|
// Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name}
|
||||||
@@ -30,23 +30,29 @@ type functionRequest struct {
|
|||||||
|
|
||||||
// functionResponse — ответ при чтении функции.
|
// functionResponse — ответ при чтении функции.
|
||||||
type functionResponse struct {
|
type functionResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
Runtime string `json:"runtime"`
|
Runtime string `json:"runtime"`
|
||||||
Entrypoint string `json:"entrypoint"`
|
Entrypoint string `json:"entrypoint"`
|
||||||
MemoryMB int32 `json:"memory_mb"`
|
MemoryMB int32 `json:"memory_mb"`
|
||||||
TimeoutSec int32 `json:"timeout_sec"`
|
TimeoutSec int32 `json:"timeout_sec"`
|
||||||
Env map[string]string `json:"env_vars"`
|
Env map[string]string `json:"env_vars"`
|
||||||
S3Bucket string `json:"s3_bucket"`
|
S3Bucket string `json:"s3_bucket"`
|
||||||
S3Key string `json:"s3_key"`
|
S3Key string `json:"s3_key"`
|
||||||
Phase slessv1alpha1.FunctionPhase `json:"phase"`
|
Phase slessv1alpha1.FunctionPhase `json:"phase"`
|
||||||
ImageRef string `json:"image_ref"`
|
ImageRef string `json:"image_ref"`
|
||||||
Message string `json:"message,omitempty"`
|
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.
|
// fnToResponse конвертирует CRD в ответ API.
|
||||||
func fnToResponse(fn *slessv1alpha1.Function) functionResponse {
|
func fnToResponse(fn *slessv1alpha1.Function) functionResponse {
|
||||||
return functionResponse{
|
resp := functionResponse{
|
||||||
Name: fn.Name,
|
Name: fn.Name,
|
||||||
Namespace: fn.Namespace,
|
Namespace: fn.Namespace,
|
||||||
Runtime: fn.Spec.Runtime,
|
Runtime: fn.Spec.Runtime,
|
||||||
@@ -60,6 +66,14 @@ func fnToResponse(fn *slessv1alpha1.Function) functionResponse {
|
|||||||
ImageRef: fn.Status.ImageRef,
|
ImageRef: fn.Status.ImageRef,
|
||||||
Message: fn.Status.Message,
|
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
|
// 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 err := h.K8s.Create(r.Context(), fn); err != nil {
|
||||||
if errors.IsAlreadyExists(err) {
|
if errors.IsAlreadyExists(err) {
|
||||||
// Если существующая функция в статусе Failed (build провалился, terraform не
|
// IsAlreadyExists может прийти из кеша controller-runtime (split-brain):
|
||||||
// добавил её в state) — удаляем её и пересоздаём, иначе клиент получит 409 навсегда.
|
// объект удалён из etcd, но кеш informer ещё южив. Делаем uncached Get:
|
||||||
|
// если реально NotFound — кеш устарел, пересоздаём.
|
||||||
|
// если существует и фаза Failed — тоже пересоздаём (build провалился, терраформ не добавил в state).
|
||||||
|
// если существует и фаза Ready/Building — возвращаем 409 (функция реально есть).
|
||||||
existing := &slessv1alpha1.Function{}
|
existing := &slessv1alpha1.Function{}
|
||||||
if getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing); getErr == nil &&
|
getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
|
||||||
existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed {
|
shouldRecreate := errors.IsNotFound(getErr) ||
|
||||||
_ = h.K8s.Delete(r.Context(), existing)
|
(getErr == nil && existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed)
|
||||||
// Создаём заново с теми же параметрами
|
if shouldRecreate {
|
||||||
|
if getErr == nil {
|
||||||
|
_ = h.K8s.Delete(r.Context(), existing)
|
||||||
|
}
|
||||||
|
// Сбрасываем ResourceVersion — при split-brain etcd считает объект новым
|
||||||
fn.ResourceVersion = ""
|
fn.ResourceVersion = ""
|
||||||
if createErr := h.K8s.Create(r.Context(), fn); createErr != nil {
|
if createErr := h.K8s.Create(r.Context(), fn); createErr != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
|
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
|
||||||
|
|||||||
Reference in New Issue
Block a user