Files
fission-console/console/internal/api/handlers.go
T
2026-04-27 07:36:06 +03:00

736 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"bytes"
"context"
"crypto/sha256"
"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"
}
var ns string
if s.testMode {
// testMode: токен — это email (sub), Deck не вызывается
if !strings.Contains(body.Token, "@") {
writeJSONError(w, http.StatusUnauthorized, "invalid token")
return
}
h32 := sha256.Sum256([]byte(body.Token))
ns = "fission-" + fmt.Sprintf("%x", h32[:8])
} else {
if err := s.validateDeckToken(body.Token, env); err != nil {
writeJSONError(w, http.StatusUnauthorized, "invalid token")
return
}
var err error
ns, err = namespaceFromJWT(body.Token)
if err != nil {
log.Printf("handleAuth: namespaceFromJWT: %v", err)
ns = s.ns
}
}
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
}