feat(web-console): create/edit/delete functions in UI, v0.1.4

- services/funcs: новые маршруты POST create-function, POST save-function,
  DELETE delete-function — создание/редактирование/удаление через браузер
- index.html: модалка создания с шаблонами Python/Node.js hello world,
  кнопки edit/delete на каждой карточке функции
- sless-funcs-service:v0.1.4 задеплоен
- examples/POSTGRES: удалены логи, .bak, backup tfstate, лишние функции
  оставлены 2 Python (pg-stats, pg-counter) + 2 Node.js (pg-info, js-idempotent)
This commit is contained in:
Naeel
2026-03-23 10:19:42 +03:00
parent 7a168185ea
commit b7fa8acf76
43 changed files with 698 additions and 1316 deletions
+235 -7
View File
@@ -1,15 +1,18 @@
// Изменено: 2026-03-21 (добавлен вывод sless_service вместе с функциями — пользователь видит оба типа как «функции»)
// Изменено: 2026-03-23 (добавлены эндпоинты создания/редактирования/удаления функций через UI)
// main.go — глобальный HTTP сервис листинга функций пользователя.
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
//
// Маршруты:
// GET /funcs — usage hint (без токена)
// GET /funcs?token=<jwt> — листинг через JWT токен
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
// GET /funcs/<namespace>/source/<fn> — прокси к оператору: файлы исходного кода (JSON)
// PATCH /funcs/<namespace>/triggers/<n> — прокси к оператору: enable/disable триггера
// GET /health — liveness/readiness probe
// GET /funcs — usage hint (без токена)
// GET /funcs?token=<jwt> — листинг через JWT токен
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
// GET /funcs/<namespace>/source/<fn> — прокси к оператору: файлы исходного кода (JSON)
// PATCH /funcs/<namespace>/triggers/<n> — прокси к оператору: enable/disable триггера
// POST /funcs/<namespace>/create-function — создать функцию через UI (zip собирается на сервере)
// POST /funcs/<namespace>/save-function/<fn> — сохранить новый код функции через UI
// DELETE /funcs/<namespace>/delete-function/<fn> — удалить функцию через UI
// GET /health — liveness/readiness probe
//
// Env vars:
// SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090)
@@ -21,7 +24,9 @@
package main
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
_ "embed"
"encoding/base64"
@@ -29,6 +34,7 @@ import (
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"regexp"
@@ -180,9 +186,35 @@ func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[st
}
proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "save-function":
// POST /funcs/{ns}/save-function/{fnName} — обновить код функции через UI
if r.Method != http.MethodPost {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxySaveFunction(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "delete-function":
// DELETE /funcs/{ns}/delete-function/{fnName} — удалить функцию через UI
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxyDeleteFunction(w, r, operatorURL, serviceToken, ns, parts[2])
return
}
}
if len(parts) == 2 && parts[1] == "create-function" {
// POST /funcs/{ns}/create-function — создать новую функцию через UI
if r.Method != http.MethodPost {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxyCreateFunction(w, r, operatorURL, serviceToken, ns)
return
}
if len(parts) > 1 {
http.NotFound(w, r)
return
@@ -549,3 +581,199 @@ func env(key, fallback string) string {
}
return fallback
}
// proxyCreateFunction обрабатывает POST /funcs/{ns}/create-function.
// Принимает JSON с кодом функции, создаёт CRD через оператор и сразу загружает zip с кодом.
// Это позволяет создать функцию целиком за один запрос из UI без использования terraform.
func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns string) {
var req struct {
Name string `json:"name"`
Runtime string `json:"runtime"`
Code string `json:"code"`
Filename string `json:"filename"`
Entrypoint string `json:"entrypoint"`
MemoryMB int `json:"memory_mb"`
TimeoutSec int `json:"timeout_sec"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
if req.Name == "" || req.Runtime == "" || req.Code == "" || req.Filename == "" || req.Entrypoint == "" {
http.Error(w, `{"error":"name, runtime, code, filename, entrypoint are required"}`, http.StatusBadRequest)
return
}
if !isValidK8sName(req.Name) {
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
return
}
if req.MemoryMB <= 0 {
req.MemoryMB = 128
}
if req.TimeoutSec <= 0 {
req.TimeoutSec = 30
}
// Шаг 1: создаём Function CRD через оператор
createBody, _ := json.Marshal(map[string]any{
"name": req.Name,
"runtime": req.Runtime,
"entrypoint": req.Entrypoint,
"memory_mb": req.MemoryMB,
"timeout_sec": req.TimeoutSec,
})
createResp, err := operatorRequest(r.Context(), http.MethodPost,
operatorURL+"/v1/namespaces/"+ns+"/functions", serviceToken, "application/json", bytes.NewReader(createBody))
if err != nil || (createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK) {
code := http.StatusBadGateway
msg := "create function CRD failed"
if createResp != nil {
b, _ := io.ReadAll(createResp.Body)
createResp.Body.Close()
msg = string(b)
code = createResp.StatusCode
}
http.Error(w, msg, code)
return
}
createResp.Body.Close()
// Шаг 2: упаковываем код в zip и загружаем в оператор
zipData, err := buildSingleFileZip(req.Filename, req.Code)
if err != nil {
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
return
}
if err := uploadZipToOperator(r.Context(), operatorURL+"/v1/namespaces/"+ns+"/functions/"+req.Name+"/upload", serviceToken, req.Filename, zipData); err != nil {
// Функция создана, но загрузка провалилась — возвращаем ошибку, UI должен показать её
http.Error(w, "upload code: "+err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, `{"status":"ok","name":%q}`, req.Name)
}
// proxySaveFunction обрабатывает POST /funcs/{ns}/save-function/{fnName}.
// Принимает JSON с новым кодом, упаковывает в zip и загружает через оператор.
// kind=service → загружает в /services/{name}/upload, иначе → /functions/{name}/upload.
func proxySaveFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
if !isValidK8sName(fnName) {
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
return
}
var req struct {
Code string `json:"code"`
Filename string `json:"filename"`
Kind string `json:"kind"` // "function" | "service"
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Code == "" || req.Filename == "" {
http.Error(w, `{"error":"code and filename are required"}`, http.StatusBadRequest)
return
}
zipData, err := buildSingleFileZip(req.Filename, req.Code)
if err != nil {
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
return
}
resourceType := "functions"
if req.Kind == "service" {
resourceType = "services"
}
uploadURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + fnName + "/upload"
if err := uploadZipToOperator(r.Context(), uploadURL, serviceToken, req.Filename, zipData); err != nil {
http.Error(w, "upload code: "+err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
}
// proxyDeleteFunction обрабатывает DELETE /funcs/{ns}/delete-function/{fnName}.
// Проксирует DELETE к оператору. kind=service → /services/{name}, иначе → /functions/{name}.
func proxyDeleteFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
if !isValidK8sName(fnName) {
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
return
}
resourceType := "functions"
if r.URL.Query().Get("kind") == "service" {
resourceType = "services"
}
target := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + fnName
resp, err := operatorRequest(r.Context(), http.MethodDelete, target, serviceToken, "", nil)
if err != nil {
http.Error(w, "operator error: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
http.Error(w, string(b), resp.StatusCode)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
}
// buildSingleFileZip упаковывает один текстовый файл в zip-архив.
// Используется при создании/редактировании функций через UI — код пишется в браузере.
func buildSingleFileZip(filename, code string) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
f, err := zw.Create(filename)
if err != nil {
return nil, err
}
if _, err := io.WriteString(f, code); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// uploadZipToOperator отправляет zip-данные как multipart/form-data field "code" на URL оператора.
func uploadZipToOperator(ctx context.Context, url, token, filename string, zipData []byte) error {
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("code", filename+".zip")
if err != nil {
return err
}
if _, err := fw.Write(zipData); err != nil {
return err
}
mw.Close()
resp, err := operatorRequest(ctx, http.MethodPost, url, token, mw.FormDataContentType(), &body)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("status %d: %s", resp.StatusCode, b)
}
return nil
}
// operatorRequest выполняет HTTP-запрос к оператору с авторизацией через serviceToken.
func operatorRequest(ctx context.Context, method, url, token, contentType string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
return http.DefaultClient.Do(req)
}