// Package api — роутинг и общие утилиты HTTP-обработчиков консоли. // // Этот файл содержит: // - Константы и regex для валидации функций // - handleFunctionsRoot / handleFunctionsAction — диспетчеры запросов к /functions // - handleAuth — аутентификация пользователя (POST /auth) // - parseTTL, normalizeMethods, normalizeRoute и другие утилиты // // Реализация конкретных операций с функциями вынесена в отдельные файлы: // - function_code.go — создание и обновление из кода // - function_archive.go — создание и обновление из zip-архива // - function_crud.go — GET, DELETE, обновление таймаута, логи // - function_invoke.go — вызов функции через Fission router // - storagesvc.go — загрузка/удаление архивов в S3 через storagesvc package api import ( "context" "encoding/json" "fmt" "net/http" "regexp" "strconv" "strings" "time" "fission-console/internal/auth" ) // 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])?$`) // ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ ЯВНОГО РАЗРЕШЕНИЯ ВЛАДЕЛЬЦА. // Любые "улучшения", рефакторинг, новые фичи — ЗАПРЕЩЕНЫ без команды. // Прецедент: самодеятельный helm upgrade сломал JWT secret router → 401 у всех функций. // maxCodeSize — максимальный размер кода функции (1 MB). // Выше — не имеет смысла для inline функции; лучше использовать Package с URL. const maxCodeSize = 1 << 20 // maxArchiveUploadSize — максимальный размер zip-архива при загрузке функции (32 MB). const maxArchiveUploadSize = 32 << 20 // fissionSourceTypeAnnotation — аннотация на Function, хранит тип источника: "code" или "archive". const fissionSourceTypeAnnotation = "fission-console/source-type" // defaultFunctionInvokeTimeout совпадает с дефолтом Fission для spec.functionTimeout. const defaultFunctionInvokeTimeout = 60 * time.Second // handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции. // GET → список всех функций со статусами, POST → создать новую. func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.handleListFunctionsWithStatus(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", "archive", "invoke", "logs", "timeout"). 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/timeout — обновление только таймаута (без замены архива/кода) if len(parts) == 2 && parts[1] == "timeout" && r.Method == http.MethodPut { s.handleUpdateFunctionTimeout(w, r, name) return } // /functions/:name/archive — обновление через zip-архив if len(parts) == 2 && parts[1] == "archive" && r.Method == http.MethodPut { s.handleUpdateFunctionArchive(w, r, name) return } // /functions/:name/invoke — вызов функции if len(parts) == 2 && parts[1] == "invoke" && r.Method == http.MethodPost { s.handleInvokeFunction(w, r, name) return } // /functions/:name/logs — логи подов функции if len(parts) == 2 && parts[1] == "logs" && r.Method == http.MethodGet { s.handleGetFunctionLogs(w, r, name) return } // /functions/:name/envvars — переменные окружения if len(parts) == 2 && parts[1] == "envvars" { switch r.Method { case http.MethodGet: s.handleGetFunctionEnvVars(w, r, name) case http.MethodPut: s.handlePutFunctionEnvVars(w, r, name) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } return } // /functions/:name/clone — клонировать функцию с новым именем if len(parts) == 2 && parts[1] == "clone" && r.Method == http.MethodPost { s.handleCloneFunction(w, r, name) return } http.NotFound(w, r) } // handleAuth обрабатывает POST /console/api/auth. // Валидирует токен через authenticator, создаёт namespace, возвращает namespace + email. 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 := normalizeEnv(body.Env) identity, err := s.authenticator.Authenticate(r.Context(), body.Token, env) if err != nil { writeJSONError(w, http.StatusUnauthorized, "invalid token") return } ns := auth.NamespaceForSub(identity.Sub) ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() if ensureErr := s.nsManager.EnsureUserNS(ctx, ns); ensureErr != nil { fmt.Printf("handleAuth: ensureUserNS %s: %v\n", ns, ensureErr) } // Провизируем Grafana Org для этого namespace (fire-and-forget, идемпотентно). go func() { if err := s.stats.EnsureOrgForNamespace(context.Background(), ns, identity.Email); err != nil { fmt.Printf("handleAuth: EnsureOrgForNamespace %s: %v\n", ns, err) } }() w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email}) } // 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 } // normalizeFunctionTimeout нормализует таймаут: отрицательные и нулевые значения // заменяются дефолтным значением Fission (defaultFunctionInvokeTimeout). func normalizeFunctionTimeout(seconds int64) int64 { if seconds <= 0 { return int64(defaultFunctionInvokeTimeout / time.Second) } return seconds } // normalizeRoute нормализует URL-маршрут: убирает пробелы, гарантирует prefix "/". func normalizeRoute(route string) string { route = strings.TrimSpace(route) if route == "" || route == "/" { return "/" } if !strings.HasPrefix(route, "/") { return "/" + route } return route } // routeAllowsMethod проверяет, допускает ли маршрут указанный HTTP-метод. // Пустой список methods означает "любой метод". func routeAllowsMethod(methods []string, method string) bool { if len(methods) == 0 { return true } for _, candidate := range methods { if strings.EqualFold(strings.TrimSpace(candidate), method) { return true } } return false } // appendUniqueMethods добавляет методы из src в dst без дублей (case-insensitive). func appendUniqueMethods(dst []string, src []string) []string { for _, method := range src { method = strings.ToUpper(strings.TrimSpace(method)) if method == "" { continue } seen := false for _, existing := range dst { if existing == method { seen = true break } } if !seen { dst = append(dst, method) } } return dst }