719 lines
27 KiB
Go
719 lines
27 KiB
Go
package api
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net"
|
||
"net/http"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"fission-console/internal/fission"
|
||
"fission-console/internal/model"
|
||
"fission-console/internal/runtime"
|
||
|
||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||
)
|
||
|
||
// validFuncName — RFC 1123 subdomain label: строчные буквы+цифры+дефис, без дефиса в начале/конце.
|
||
// Максимум 57 символов (не 63): самый длинный суффикс "-route" (HTTPTrigger) = 6 символов.
|
||
// 63 - 6 = 57. Fission webhook требует все связанные объекты <= 63 символов.
|
||
var validFuncName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
|
||
|
||
// maxCodeSize — максимальный размер кода функции (1 MB).
|
||
// Выше — не имеет смысла для inline функции; лучше использовать Package с URL.
|
||
const maxCodeSize = 1 << 20
|
||
|
||
func buildDeployArchive(lang, code string) ([]byte, error) {
|
||
switch lang {
|
||
case "nodejs":
|
||
return runtime.BuildJSDeployZip(code)
|
||
case "php":
|
||
return runtime.BuildScriptZip(code, "main.php")
|
||
case "ruby":
|
||
return runtime.BuildScriptZip(code, "handler.rb")
|
||
default:
|
||
return []byte(code), nil
|
||
}
|
||
}
|
||
|
||
// handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции.
|
||
// GET → список всех функций, POST → создать новую.
|
||
func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) {
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
s.handleList(fission.FunctionGVR)(w, r)
|
||
case http.MethodPost:
|
||
s.handleCreateFunction(w, r)
|
||
default:
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
}
|
||
|
||
// handleFunctionsAction обрабатывает запросы к /console/api/functions/:name[/action].
|
||
// Парсит имя функции и опциональный sub-path ("code", "invoke").
|
||
func (s *Server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) {
|
||
// Убираем оба возможных префикса (legacy /api/ и основной /console/api/)
|
||
path := strings.TrimPrefix(r.URL.Path, "/api/functions/")
|
||
if path == r.URL.Path {
|
||
path = strings.TrimPrefix(r.URL.Path, "/console/api/functions/")
|
||
}
|
||
path = strings.Trim(path, "/")
|
||
if path == "" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
parts := strings.Split(path, "/")
|
||
name := strings.TrimSpace(parts[0])
|
||
if name == "" {
|
||
writeJSONError(w, http.StatusBadRequest, "function name is required")
|
||
return
|
||
}
|
||
|
||
if len(parts) == 1 {
|
||
// /functions/:name — CRUD операции с конкретной функцией
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
s.handleGetFunction(w, r, name)
|
||
case http.MethodDelete:
|
||
s.handleDeleteFunction(w, r, name)
|
||
default:
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
return
|
||
}
|
||
|
||
// /functions/:name/code — обновление кода
|
||
if len(parts) == 2 && parts[1] == "code" && r.Method == http.MethodPut {
|
||
s.handleUpdateFunctionCode(w, r, name)
|
||
return
|
||
}
|
||
|
||
// /functions/:name/invoke — вызов функции
|
||
if len(parts) == 2 && parts[1] == "invoke" && r.Method == http.MethodPost {
|
||
s.handleInvokeFunction(w, r, name)
|
||
return
|
||
}
|
||
|
||
http.NotFound(w, r)
|
||
}
|
||
|
||
// handleCreateFunction создаёт новую функцию: Package + Function + HTTPTrigger.
|
||
//
|
||
// Порядок создания: Package → Function → HTTPTrigger.
|
||
// При ошибке на любом шаге откатываем уже созданные объекты (best-effort).
|
||
// TTL парсится ДО создания объектов — невалидный TTL не оставляет мусор.
|
||
func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
|
||
ns := s.userNS(r)
|
||
|
||
var req model.CreateFunctionRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
|
||
return
|
||
}
|
||
|
||
// Гарантируем namespace — на случай прямого вызова API без handleAuth
|
||
nsCtx, nsCancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||
defer nsCancel()
|
||
if err := s.nsManager.EnsureUserNS(nsCtx, ns); err != nil {
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err))
|
||
return
|
||
}
|
||
|
||
req.Name = strings.TrimSpace(req.Name)
|
||
req.Language = strings.TrimSpace(req.Language)
|
||
req.Environment = strings.TrimSpace(req.Environment)
|
||
req.Code = strings.TrimSpace(req.Code)
|
||
req.Entrypoint = strings.TrimSpace(req.Entrypoint)
|
||
req.Route = strings.TrimSpace(req.Route)
|
||
|
||
// Валидация имени
|
||
if req.Name != "" && (!validFuncName.MatchString(req.Name) || len(req.Name) > 57) {
|
||
writeJSONError(w, http.StatusBadRequest, "invalid function name: must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ and be <= 57 chars")
|
||
return
|
||
}
|
||
if len(req.Code) > maxCodeSize {
|
||
writeJSONError(w, http.StatusBadRequest, "code exceeds 1MB limit")
|
||
return
|
||
}
|
||
|
||
// Lazy создание Environment по языку (если язык указан явно)
|
||
if req.Language != "" {
|
||
envCtx, envCancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||
defer envCancel()
|
||
envName, err := fission.EnsureEnvironment(envCtx, s.dyn, ns, req.Language)
|
||
if err != nil {
|
||
if strings.Contains(err.Error(), "unsupported language") {
|
||
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||
} else {
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure environment: %v", err))
|
||
}
|
||
return
|
||
}
|
||
req.Environment = envName
|
||
}
|
||
|
||
if req.Name == "" || req.Environment == "" || req.Code == "" {
|
||
writeJSONError(w, http.StatusBadRequest, "name, environment/language and code are required")
|
||
return
|
||
}
|
||
if req.Entrypoint == "" {
|
||
req.Entrypoint = runtime.DefaultEntrypoint(req.Language)
|
||
}
|
||
if req.Route == "" {
|
||
// Namespace-prefix route: избегаем коллизий между пользователями
|
||
// (разные пользователи могут создать функцию с одинаковым именем)
|
||
nsShort := ns
|
||
if len(nsShort) > 12 {
|
||
nsShort = nsShort[len(nsShort)-12:]
|
||
}
|
||
req.Route = "/" + nsShort + "/" + req.Name
|
||
}
|
||
if !strings.HasPrefix(req.Route, "/") {
|
||
req.Route = "/" + req.Route
|
||
}
|
||
req.Methods = normalizeMethods(req.Methods)
|
||
|
||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||
defer cancel()
|
||
|
||
// Проверяем что environment существует (мог быть задан явно без language)
|
||
if _, err := s.dyn.Resource(fission.EnvironmentGVR).Namespace(ns).Get(ctx, req.Environment, metav1.GetOptions{}); err != nil {
|
||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("environment %q not found: %v", req.Environment, err))
|
||
return
|
||
}
|
||
|
||
pkgName := req.Name + "-pkg"
|
||
triggerName := req.Name + "-route"
|
||
|
||
methodValues := make([]any, 0, len(req.Methods))
|
||
for _, method := range req.Methods {
|
||
methodValues = append(methodValues, method)
|
||
}
|
||
|
||
// Строим Package spec в зависимости от языка:
|
||
// - Go: source package → builder job компилирует в .so плагин
|
||
// - Node.js: deployment zip с ESM wrapper (package.json + main.js)
|
||
// - Остальные: deployment literal с кодом напрямую
|
||
var pkgSpec map[string]any
|
||
if req.Language == "go" {
|
||
srcZip, err := runtime.BuildGoSourceZip(req.Code)
|
||
if err != nil {
|
||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("build go source archive: %v", err))
|
||
return
|
||
}
|
||
literal := base64.StdEncoding.EncodeToString(srcZip)
|
||
pkgSpec = map[string]any{
|
||
"source": map[string]any{
|
||
"type": "literal",
|
||
"literal": literal,
|
||
},
|
||
"deployment": map[string]any{},
|
||
"environment": map[string]any{"name": req.Environment, "namespace": ns},
|
||
"buildcommand": "build",
|
||
}
|
||
} else {
|
||
deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code)
|
||
if archiveErr != nil {
|
||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr))
|
||
return
|
||
}
|
||
pkgSpec = map[string]any{
|
||
"deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)},
|
||
"environment": map[string]any{"name": req.Environment, "namespace": ns},
|
||
"source": map[string]any{},
|
||
}
|
||
}
|
||
|
||
pkg := &unstructured.Unstructured{Object: map[string]any{
|
||
"apiVersion": "fission.io/v1",
|
||
"kind": "Package",
|
||
"metadata": map[string]any{"name": pkgName, "namespace": ns},
|
||
"spec": pkgSpec,
|
||
}}
|
||
|
||
// Парсим TTL ДО создания K8s ресурсов — невалидный TTL не оставляет мусор
|
||
fnAnnotations := map[string]any{
|
||
"fission-console/language": req.Language,
|
||
}
|
||
if req.TTL != "" {
|
||
expiresAt, ttlErr := parseTTL(req.TTL)
|
||
if ttlErr != nil {
|
||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid ttl %q: %v", req.TTL, ttlErr))
|
||
return
|
||
}
|
||
fnAnnotations["fission-console/expires-at"] = expiresAt.UTC().Format(time.RFC3339)
|
||
}
|
||
|
||
if _, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil {
|
||
if apierrors.IsAlreadyExists(err) {
|
||
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name))
|
||
return
|
||
}
|
||
if apierrors.IsInvalid(err) {
|
||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid function spec: %v", err))
|
||
return
|
||
}
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err))
|
||
return
|
||
}
|
||
|
||
fn := &unstructured.Unstructured{Object: map[string]any{
|
||
"apiVersion": "fission.io/v1",
|
||
"kind": "Function",
|
||
"metadata": map[string]any{"name": req.Name, "namespace": ns, "annotations": fnAnnotations},
|
||
"spec": map[string]any{
|
||
"environment": map[string]any{"name": req.Environment, "namespace": ns},
|
||
"InvokeStrategy": map[string]any{
|
||
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
|
||
"StrategyType": "execution",
|
||
},
|
||
"package": map[string]any{
|
||
"packageref": map[string]any{"name": pkgName, "namespace": ns},
|
||
"functionName": req.Entrypoint,
|
||
},
|
||
},
|
||
}}
|
||
|
||
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil {
|
||
// Откатываем Package если Function не создалась
|
||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
|
||
if apierrors.IsAlreadyExists(err) {
|
||
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name))
|
||
return
|
||
}
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create function: %v", err))
|
||
return
|
||
}
|
||
|
||
httpTrigger := &unstructured.Unstructured{Object: map[string]any{
|
||
"apiVersion": "fission.io/v1",
|
||
"kind": "HTTPTrigger",
|
||
"metadata": map[string]any{"name": triggerName, "namespace": ns},
|
||
"spec": map[string]any{
|
||
"relativeurl": req.Route,
|
||
"methods": methodValues,
|
||
"createingress": true,
|
||
"functionref": map[string]any{"type": "name", "name": req.Name},
|
||
},
|
||
}}
|
||
|
||
if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, httpTrigger, metav1.CreateOptions{}); err != nil {
|
||
// Откатываем Function и Package
|
||
_ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, req.Name, metav1.DeleteOptions{})
|
||
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create httptrigger: %v", err))
|
||
return
|
||
}
|
||
|
||
writeAnyJSON(w, http.StatusCreated, map[string]any{
|
||
"name": req.Name,
|
||
"package": pkgName,
|
||
"httptrigger": triggerName,
|
||
"route": req.Route,
|
||
"expires_at": fnAnnotations["fission-console/expires-at"],
|
||
})
|
||
}
|
||
|
||
// handleGetFunction возвращает детали функции: код, environment, route, methods.
|
||
func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name string) {
|
||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||
defer cancel()
|
||
ns := s.userNS(r)
|
||
|
||
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||
if err != nil {
|
||
status := http.StatusBadGateway
|
||
if apierrors.IsNotFound(err) {
|
||
status = http.StatusNotFound
|
||
}
|
||
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
|
||
return
|
||
}
|
||
|
||
packageName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
||
environment, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
||
entrypoint, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "functionName")
|
||
|
||
// Извлекаем исходный код из Package (пробуем source.literal, потом deployment.literal, потом url)
|
||
code := ""
|
||
if packageName != "" {
|
||
pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, packageName, metav1.GetOptions{})
|
||
if pkgErr == nil {
|
||
code = extractPackageSourceCode(ctx, s, pkg)
|
||
}
|
||
}
|
||
|
||
// Ищем HTTPTrigger для получения route и methods
|
||
route := ""
|
||
methods := []string{}
|
||
triggers, trigErr := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||
if trigErr == nil {
|
||
for _, trig := range triggers.Items {
|
||
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
|
||
if refName != name {
|
||
continue
|
||
}
|
||
route, _, _ = unstructured.NestedString(trig.Object, "spec", "relativeurl")
|
||
methods, _, _ = unstructured.NestedStringSlice(trig.Object, "spec", "methods")
|
||
break
|
||
}
|
||
}
|
||
|
||
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||
"name": name,
|
||
"namespace": ns,
|
||
"environment": environment,
|
||
"package": packageName,
|
||
"entrypoint": entrypoint,
|
||
"code": code,
|
||
"route": route,
|
||
"methods": methods,
|
||
"raw": fn.Object,
|
||
})
|
||
}
|
||
|
||
// handleUpdateFunctionCode обновляет код уже существующей функции.
|
||
// Обновляет Package.spec.deployment.literal и синхронизирует resourceVersion в Function.
|
||
//
|
||
// Почему нужно обновлять resourceVersion в Function:
|
||
// Fission executor кэширует Package по resourceVersion. Без обновления в Function
|
||
// executor будет использовать старый код до перезапуска pod.
|
||
func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request, name string) {
|
||
var req model.UpdateCodeRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
|
||
return
|
||
}
|
||
req.Code = strings.TrimSpace(req.Code)
|
||
if req.Code == "" {
|
||
writeJSONError(w, http.StatusBadRequest, "code is required")
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||
defer cancel()
|
||
ns := s.userNS(r)
|
||
|
||
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||
if err != nil {
|
||
status := http.StatusBadGateway
|
||
if apierrors.IsNotFound(err) {
|
||
status = http.StatusNotFound
|
||
}
|
||
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
|
||
return
|
||
}
|
||
|
||
pkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
||
if pkgName == "" {
|
||
writeJSONError(w, http.StatusBadGateway, "function has no package reference")
|
||
return
|
||
}
|
||
|
||
pkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, pkgName, metav1.GetOptions{})
|
||
if err != nil {
|
||
status := http.StatusBadGateway
|
||
if apierrors.IsNotFound(err) {
|
||
status = http.StatusNotFound
|
||
}
|
||
writeJSONError(w, status, fmt.Sprintf("get package %q: %v", pkgName, err))
|
||
return
|
||
}
|
||
|
||
// Определяем язык из аннотации — нужен для правильной упаковки
|
||
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
|
||
deployBytes, archiveErr := buildDeployArchive(lang, req.Code)
|
||
if archiveErr != nil {
|
||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr))
|
||
return
|
||
}
|
||
|
||
literal := base64.StdEncoding.EncodeToString(deployBytes)
|
||
if err := unstructured.SetNestedField(pkg.Object, literal, "spec", "deployment", "literal"); err != nil {
|
||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set package literal: %v", err))
|
||
return
|
||
}
|
||
|
||
updatedPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Update(ctx, pkg, metav1.UpdateOptions{})
|
||
if err != nil {
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update package %q: %v", pkgName, err))
|
||
return
|
||
}
|
||
|
||
// Синхронизируем resourceVersion в Function.spec.package.packageref
|
||
// Это триггерит executor перезагрузить код в pool pod
|
||
if err := unstructured.SetNestedField(fn.Object, updatedPkg.GetResourceVersion(), "spec", "package", "packageref", "resourceversion"); err != nil {
|
||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function package resourceversion: %v", err))
|
||
return
|
||
}
|
||
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q package ref: %v", name, err))
|
||
return
|
||
}
|
||
|
||
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||
"updated": true,
|
||
"package": pkgName,
|
||
"package_resourceversion": updatedPkg.GetResourceVersion(),
|
||
})
|
||
}
|
||
|
||
// handleInvokeFunction вызывает функцию через Fission router.
|
||
// Определяет реальный URL из HTTPTrigger, выбирает метод (POST/GET).
|
||
func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, name string) {
|
||
bodyBytes, err := io.ReadAll(r.Body)
|
||
if err != nil {
|
||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err))
|
||
return
|
||
}
|
||
if len(bytes.TrimSpace(bodyBytes)) == 0 {
|
||
bodyBytes = []byte("{}")
|
||
}
|
||
|
||
invokeTimeout := s.invokeTimeout
|
||
if invokeTimeout <= 0 {
|
||
invokeTimeout = 20 * time.Second
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(r.Context(), invokeTimeout)
|
||
defer cancel()
|
||
ns := s.userNS(r)
|
||
|
||
// Проверяем существование функции до вызова — лучше 404 чем непонятный timeout
|
||
if _, err2 := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}); err2 != nil {
|
||
if apierrors.IsNotFound(err2) {
|
||
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", name))
|
||
return
|
||
}
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", name, err2))
|
||
return
|
||
}
|
||
|
||
// Ищем HTTPTrigger чтобы получить реальный URL и метод
|
||
invokeURL := fmt.Sprintf("%s/fission-function/v2/functions/%s", s.routerURL, name)
|
||
invokeMethod := http.MethodPost
|
||
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||
if err == nil {
|
||
for _, trig := range triggers.Items {
|
||
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
|
||
if refName != name {
|
||
continue
|
||
}
|
||
route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
|
||
methods, _, _ := unstructured.NestedStringSlice(trig.Object, "spec", "methods")
|
||
hasPost, hasGet := false, false
|
||
for _, m := range methods {
|
||
switch strings.ToUpper(strings.TrimSpace(m)) {
|
||
case http.MethodPost:
|
||
hasPost = true
|
||
case http.MethodGet:
|
||
hasGet = true
|
||
}
|
||
}
|
||
if route != "" {
|
||
if !strings.HasPrefix(route, "/") {
|
||
route = "/" + route
|
||
}
|
||
invokeURL = s.routerURL + route
|
||
// Если функция поддерживает только GET — используем GET
|
||
if !hasPost && hasGet {
|
||
invokeMethod = http.MethodGet
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
start := time.Now()
|
||
var invokeBody io.Reader
|
||
if invokeMethod == http.MethodPost {
|
||
invokeBody = bytes.NewReader(bodyBytes)
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(ctx, invokeMethod, invokeURL, invokeBody)
|
||
if err != nil {
|
||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err))
|
||
return
|
||
}
|
||
if invokeMethod == http.MethodPost {
|
||
req.Header.Set("Content-Type", "application/json")
|
||
}
|
||
if token := s.getRouterToken(); token != "" {
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
}
|
||
|
||
resp, err := s.http.Do(req)
|
||
if err != nil {
|
||
// Отличаем timeout от сетевой ошибки — timeout часто означает что функция не специализировалась
|
||
if errors.Is(err, context.DeadlineExceeded) {
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s: function specialization likely failed (for example, syntax error)", name, invokeTimeout))
|
||
return
|
||
}
|
||
var netErr net.Error
|
||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s: function specialization likely failed (for example, syntax error)", name, invokeTimeout))
|
||
return
|
||
}
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q: %v", name, err))
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||
"status": resp.StatusCode,
|
||
"latency_ms": time.Since(start).Milliseconds(),
|
||
"invoke_url": invokeURL,
|
||
"response_raw": string(respBody),
|
||
})
|
||
}
|
||
|
||
// handleDeleteFunction удаляет функцию и связанные объекты: HTTPTrigger, Package.
|
||
// После удаления вызывает CleanupEnvironmentIfUnused — убирает environment если язык больше не используется.
|
||
func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) {
|
||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||
defer cancel()
|
||
ns := s.userNS(r)
|
||
|
||
// Получаем Function чтобы знать pkgName и envName для cleanup
|
||
var pkgName, envName string
|
||
fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
|
||
if err != nil {
|
||
if apierrors.IsNotFound(err) {
|
||
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", name))
|
||
return
|
||
}
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", name, err))
|
||
return
|
||
}
|
||
pkgName, _, _ = unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
||
envName, _, _ = unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
||
|
||
// Удаляем связанные HTTPTrigger-ы
|
||
triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||
if err == nil {
|
||
for _, trig := range triggers.Items {
|
||
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
|
||
if refName == name {
|
||
_ = s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{})
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete function %q: %v", name, err))
|
||
return
|
||
}
|
||
|
||
if pkgName != "" {
|
||
if err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete package %q: %v", pkgName, err))
|
||
return
|
||
}
|
||
}
|
||
|
||
// Убираем environment pool pods если язык больше не используется (best-effort)
|
||
if envName != "" {
|
||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cleanupCancel()
|
||
fission.CleanupEnvironmentIfUnused(cleanupCtx, s.dyn, ns, envName)
|
||
}
|
||
|
||
// (reconciler NS удалён — за FISSION_RESOURCE_NAMESPACES теперь отвечает Layer 1 NSWatcher)
|
||
|
||
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name, "package": pkgName})
|
||
}
|
||
|
||
// handleAuth обрабатывает POST /console/api/auth.
|
||
// Валидирует токен, создаёт namespace, возвращает namespace пользователя.
|
||
func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
return
|
||
}
|
||
|
||
var body struct {
|
||
Token string `json:"token"`
|
||
Env string `json:"env"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Token) == "" {
|
||
writeJSONError(w, http.StatusBadRequest, "token required")
|
||
return
|
||
}
|
||
|
||
env := strings.TrimSpace(strings.ToLower(body.Env))
|
||
if _, ok := deckAPIs[env]; !ok {
|
||
env = "test"
|
||
}
|
||
|
||
ns, err := s.resolveNamespaceForToken(body.Token, env, s.testMode)
|
||
if err != nil {
|
||
writeJSONError(w, http.StatusUnauthorized, "invalid token")
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||
defer cancel()
|
||
if ensureErr := s.nsManager.EnsureUserNS(ctx, ns); ensureErr != nil {
|
||
log.Printf("handleAuth: ensureUserNS %s: %v", ns, ensureErr)
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns})
|
||
}
|
||
|
||
// parseTTL парсит строку TTL и возвращает время истечения.
|
||
// Поддерживаемые форматы: Go duration (1h, 30m, 24h) и дни (1d, 7d, 30d).
|
||
// Суффикс "d" не поддерживается стандартным time.ParseDuration — обрабатываем отдельно.
|
||
func parseTTL(ttl string) (time.Time, error) {
|
||
if strings.HasSuffix(ttl, "d") {
|
||
days, err := strconv.Atoi(strings.TrimSuffix(ttl, "d"))
|
||
if err != nil || days <= 0 {
|
||
return time.Time{}, fmt.Errorf("invalid days value: %q", ttl)
|
||
}
|
||
return time.Now().Add(time.Duration(days) * 24 * time.Hour), nil
|
||
}
|
||
d, err := time.ParseDuration(ttl)
|
||
if err != nil {
|
||
return time.Time{}, err
|
||
}
|
||
if d <= 0 {
|
||
return time.Time{}, fmt.Errorf("ttl must be positive")
|
||
}
|
||
return time.Now().Add(d), nil
|
||
}
|
||
|
||
// normalizeMethods приводит список HTTP методов к верхнему регистру, убирает дубли.
|
||
// Если список пустой или все элементы пустые — возвращает ["GET"].
|
||
func normalizeMethods(in []string) []string {
|
||
if len(in) == 0 {
|
||
return []string{"GET"}
|
||
}
|
||
out := make([]string, 0, len(in))
|
||
seen := map[string]bool{}
|
||
for _, method := range in {
|
||
m := strings.ToUpper(strings.TrimSpace(method))
|
||
if m == "" || seen[m] {
|
||
continue
|
||
}
|
||
seen[m] = true
|
||
out = append(out, m)
|
||
}
|
||
if len(out) == 0 {
|
||
return []string{"GET"}
|
||
}
|
||
return out
|
||
}
|