restructure: console→client-console, add admin-console skeleton, move docs to doc/
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user