feat(web-console): deps, rename, Building poll+timer, kind=service — v0.1.5

This commit is contained in:
Naeel
2026-03-23 10:41:43 +03:00
parent b7fa8acf76
commit 442ba8bc2f
3 changed files with 374 additions and 58 deletions
+201 -41
View File
@@ -1,18 +1,20 @@
// Изменено: 2026-03-23 (добавлены эндпоинты создания/редактирования/удаления функций через UI)
// Изменено: 2026-03-23 (deps поддержка, Building poll, rename, kind=service по умолч.)
// 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 триггера
// 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
// 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 /funcs/<namespace>/fn-status/<fn> — прокси: текущий phase+url функции (для UI polling)
// POST /funcs/<namespace>/create-function — создать функцию через UI (kind=service по умолч.)
// POST /funcs/<namespace>/save-function/<fn> — обновить код через UI (zip с code+deps)
// POST /funcs/<namespace>/rename-function/<fn> — переименовать: delete old + create new с тем же кодом
// 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)
@@ -186,6 +188,14 @@ func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[st
}
proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "fn-status":
// GET /funcs/{ns}/fn-status/{fn} — текущий phase+url для UI polling
if r.Method != http.MethodGet {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxyFnStatus(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "save-function":
// POST /funcs/{ns}/save-function/{fnName} — обновить код функции через UI
if r.Method != http.MethodPost {
@@ -194,6 +204,14 @@ func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[st
}
proxySaveFunction(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "rename-function":
// POST /funcs/{ns}/rename-function/{fn} — переименовать: delete+create с тем же кодом
if r.Method != http.MethodPost {
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
return
}
proxyRenameFunction(w, r, operatorURL, serviceToken, ns, parts[2])
return
case "delete-function":
// DELETE /funcs/{ns}/delete-function/{fnName} — удалить функцию через UI
if r.Method != http.MethodDelete {
@@ -583,17 +601,20 @@ func env(key, fallback string) string {
}
// proxyCreateFunction обрабатывает POST /funcs/{ns}/create-function.
// Принимает JSON с кодом функции, создаёт CRD через оператор и сразу загружает zip с кодом.
// Это позволяет создать функцию целиком за один запрос из UI без использования terraform.
// Создаёт функцию как sless_service (always-on, с URL) если kind не указан.
// Принимает code (основной файл) + deps (requirements.txt / package.json).
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"`
Name string `json:"name"`
Runtime string `json:"runtime"`
Kind string `json:"kind"` // "service" | "function"; default "service"
Code string `json:"code"`
Filename string `json:"filename"`
Deps string `json:"deps"` // содержимое requirements.txt / package.json
DepsFile string `json:"deps_filename"` // "requirements.txt" или "package.json"
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)
@@ -607,6 +628,9 @@ func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
http.Error(w, `{"error":"invalid function name"}`, http.StatusBadRequest)
return
}
if req.Kind == "" {
req.Kind = "service" // always-on по умолчанию — сразу получает URL
}
if req.MemoryMB <= 0 {
req.MemoryMB = 128
}
@@ -614,7 +638,12 @@ func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
req.TimeoutSec = 30
}
// Шаг 1: создаём Function CRD через оператор
resourceType := "functions"
if req.Kind == "service" {
resourceType = "services"
}
// Шаг 1: создаём CRD через оператор (/functions или /services)
createBody, _ := json.Marshal(map[string]any{
"name": req.Name,
"runtime": req.Runtime,
@@ -622,11 +651,11 @@ func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
"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))
createURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType
createResp, err := operatorRequest(r.Context(), http.MethodPost, createURL, 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"
msg := "create CRD failed"
if createResp != nil {
b, _ := io.ReadAll(createResp.Body)
createResp.Body.Close()
@@ -638,25 +667,29 @@ func proxyCreateFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
}
createResp.Body.Close()
// Шаг 2: упаковываем код в zip и загружаем в оператор
zipData, err := buildSingleFileZip(req.Filename, req.Code)
// Шаг 2: упаковываем code + deps в zip и загружаем
zipFiles := map[string]string{req.Filename: req.Code}
if req.Deps != "" && req.DepsFile != "" {
zipFiles[req.DepsFile] = req.Deps
}
zipData, err := buildZipFiles(zipFiles)
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 должен показать её
uploadURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType + "/" + req.Name + "/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")
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, `{"status":"ok","name":%q}`, req.Name)
fmt.Fprintf(w, `{"status":"ok","name":%q,"kind":%q}`, req.Name, req.Kind)
}
// proxySaveFunction обрабатывает POST /funcs/{ns}/save-function/{fnName}.
// Принимает JSON с новым кодом, упаковывает в zip и загружает через оператор.
// Принимает code + deps (опционально), упаковывает в 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) {
@@ -666,14 +699,20 @@ func proxySaveFunction(w http.ResponseWriter, r *http.Request, operatorURL, serv
var req struct {
Code string `json:"code"`
Filename string `json:"filename"`
Kind string `json:"kind"` // "function" | "service"
Kind string `json:"kind"` // "function" | "service"
Deps string `json:"deps"` // содержимое requirements.txt / package.json
DepsFile string `json:"deps_filename"` // имя файла зависимостей
}
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)
zipFiles := map[string]string{req.Filename: req.Code}
if req.Deps != "" && req.DepsFile != "" {
zipFiles[req.DepsFile] = req.Deps
}
zipData, err := buildZipFiles(zipFiles)
if err != nil {
http.Error(w, `{"error":"zip build failed"}`, http.StatusInternalServerError)
return
@@ -720,22 +759,143 @@ func proxyDeleteFunction(w http.ResponseWriter, r *http.Request, operatorURL, se
fmt.Fprintf(w, `{"status":"ok","name":%q}`, fnName)
}
// buildSingleFileZip упаковывает один текстовый файл в zip-архив.
// Используется при создании/редактировании функций через UI — код пишется в браузере.
func buildSingleFileZip(filename, code string) ([]byte, error) {
// buildZipFiles упаковывает набор текстовых файлов {filename→content} в zip-архив.
// Используется при создании/редактировании функций через UI.
// Поддерживает code + deps (requirements.txt / package.json) в одном архиве.
func buildZipFiles(files map[string]string) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
f, err := zw.Create(filename)
for name, content := range files {
f, err := zw.Create(name)
if err != nil {
return nil, err
}
if _, err := io.WriteString(f, content); err != nil {
return nil, err
}
}
return buf.Bytes(), zw.Close()
}
// proxyFnStatus отдаёт текущий phase+url функции/сервиса — используется для UI polling.
// Клиент опрашивает каждые 5с пока phase=Building, потом останавливается.
func proxyFnStatus(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, fnName string) {
if !isValidK8sName(fnName) {
http.Error(w, `{"error":"invalid name"}`, http.StatusBadRequest)
return
}
resourceType := "functions"
if r.URL.Query().Get("kind") == "service" {
resourceType = "services"
}
resp, err := operatorRequest(r.Context(), http.MethodGet,
operatorURL+"/v1/namespaces/"+ns+"/"+resourceType+"/"+fnName, serviceToken, "", nil)
if err != nil {
return nil, err
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if _, err := io.WriteString(f, code); err != nil {
return nil, err
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(b) //nolint:errcheck
}
// proxyRenameFunction реализует переименование функции: delete old + create new + upload old code.
// Это атомарная операция с точки зрения UI, но не транзакционная на уровне k8s.
// При сбое на шагах 3-5 старая функция может остаться (UI покажет ошибку).
func proxyRenameFunction(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, oldName string) {
if !isValidK8sName(oldName) {
http.Error(w, `{"error":"invalid old name"}`, http.StatusBadRequest)
return
}
if err := zw.Close(); err != nil {
return nil, err
var req struct {
NewName string `json:"new_name"`
Kind string `json:"kind"`
}
return buf.Bytes(), nil
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.NewName == "" {
http.Error(w, `{"error":"new_name required"}`, http.StatusBadRequest)
return
}
if !isValidK8sName(req.NewName) {
http.Error(w, `{"error":"invalid new_name"}`, http.StatusBadRequest)
return
}
resourceType := "functions"
if req.Kind == "service" {
resourceType = "services"
}
ctx := r.Context()
baseURL := operatorURL + "/v1/namespaces/" + ns + "/" + resourceType
// Шаг 1: получаем конфиг старой функции (runtime, entrypoint, memory, timeout)
cfgResp, err := operatorRequest(ctx, http.MethodGet, baseURL+"/"+oldName, serviceToken, "", nil)
if err != nil || cfgResp.StatusCode != http.StatusOK {
http.Error(w, "get old config failed", http.StatusBadGateway)
return
}
var cfg struct {
Runtime string `json:"runtime"`
Entrypoint string `json:"entrypoint"`
MemoryMB int `json:"memory_mb"`
TimeoutSec int `json:"timeout_sec"`
Env map[string]string `json:"env"`
}
json.NewDecoder(cfgResp.Body).Decode(&cfg) //nolint:errcheck
cfgResp.Body.Close()
// Шаг 2: получаем исходный код старой функции
srcResp, _ := operatorRequest(ctx, http.MethodGet, baseURL+"/"+oldName+"/source", serviceToken, "", nil)
zipFiles := map[string]string{}
if srcResp != nil && srcResp.StatusCode == http.StatusOK {
var files []struct {
Name string `json:"name"`
Content string `json:"content"`
Binary bool `json:"binary"`
}
if json.NewDecoder(srcResp.Body).Decode(&files) == nil {
for _, f := range files {
if !f.Binary {
zipFiles[f.Name] = f.Content
}
}
}
srcResp.Body.Close()
}
// Шаг 3: создаём новую функцию с новым именем
createBody, _ := json.Marshal(map[string]any{
"name": req.NewName, "runtime": cfg.Runtime, "entrypoint": cfg.Entrypoint,
"memory_mb": cfg.MemoryMB, "timeout_sec": cfg.TimeoutSec, "env": cfg.Env,
})
createResp, err := operatorRequest(ctx, http.MethodPost, baseURL, serviceToken, "application/json", bytes.NewReader(createBody))
if err != nil || (createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK) {
msg := "create new failed"
if createResp != nil {
b, _ := io.ReadAll(createResp.Body)
createResp.Body.Close()
msg = string(b)
}
http.Error(w, msg, http.StatusBadGateway)
return
}
createResp.Body.Close()
// Шаг 4: загружаем старый код в новую функцию (если есть)
if len(zipFiles) > 0 {
if zipData, err := buildZipFiles(zipFiles); err == nil {
_ = uploadZipToOperator(ctx, baseURL+"/"+req.NewName+"/upload", serviceToken, req.NewName, zipData)
}
}
// Шаг 5: удаляем старую функцию
delResp, _ := operatorRequest(ctx, http.MethodDelete, baseURL+"/"+oldName, serviceToken, "", nil)
if delResp != nil {
delResp.Body.Close()
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","old":%q,"new":%q,"kind":%q}`, oldName, req.NewName, req.Kind)
}
// uploadZipToOperator отправляет zip-данные как multipart/form-data field "code" на URL оператора.