295 lines
12 KiB
Go
295 lines
12 KiB
Go
package api
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"fission-console/internal/auth"
|
||
"fission-console/internal/billing"
|
||
"fission-console/internal/cloud"
|
||
"fission-console/internal/fission"
|
||
"fission-console/internal/stats"
|
||
"fission-console/ui"
|
||
|
||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||
"k8s.io/client-go/dynamic"
|
||
"k8s.io/client-go/kubernetes"
|
||
)
|
||
|
||
// defaultSATokenPath — путь к service account токену внутри pod-а.
|
||
// Используется для авторизации запросов от console к Fission router.
|
||
const defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||
|
||
// Server — основная структура HTTP сервера.
|
||
// Содержит все зависимости: kubernetes client, конфиги, кэши.
|
||
type Server struct {
|
||
dyn dynamic.Interface
|
||
kube kubernetes.Interface // typed client — только для логов подов
|
||
ns string // системный namespace (fallback, обычно "fission")
|
||
routerURL string
|
||
http *http.Client
|
||
|
||
saTokenPath string
|
||
invokeTimeout time.Duration
|
||
|
||
authUser string
|
||
authPass string
|
||
storagesvcURL string // URL Fission storagesvc для загрузки архивов кода в S3
|
||
|
||
// --- ai/ask feature (удалить блок целиком чтобы выкосить) ---
|
||
llmURL string // FISSION_LLM_URL
|
||
llmKey string // FISSION_LLM_KEY
|
||
// --- end ai/ask feature ---
|
||
|
||
// tokenMu защищает кэш JWT токена для аутентификации в Fission router.
|
||
tokenMu sync.Mutex
|
||
cachedJWT string
|
||
tokenExpAt time.Time
|
||
|
||
// authenticator — слой аутентификации. Сервер не знает деталей реализации.
|
||
authenticator auth.Authenticator
|
||
|
||
// nsManager управляет жизненным циклом пользовательских namespace-ов.
|
||
nsManager *cloud.NSManager
|
||
|
||
// billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан.
|
||
billing billing.Store
|
||
|
||
// stats — аналитический слой (Grafana Organizations). NoopProvider если не настроен.
|
||
stats stats.StatsProvider
|
||
}
|
||
|
||
// Config содержит все параметры для создания Server.
|
||
type Config struct {
|
||
Dyn dynamic.Interface
|
||
Kube kubernetes.Interface
|
||
Namespace string
|
||
RouterURL string
|
||
HTTPTimeout time.Duration
|
||
InvokeTimeout time.Duration
|
||
SATokenPath string
|
||
AuthUser string
|
||
AuthPass string
|
||
StoragesvcURL string
|
||
Authenticator auth.Authenticator // слой аутентификации
|
||
LLMUrl string
|
||
LLMKey string
|
||
Billing billing.Store // слой статистики (NoopStore если не задан)
|
||
Stats stats.StatsProvider // аналитика (NoopProvider если не настроен)
|
||
}
|
||
|
||
// NewServer создаёт и настраивает HTTP Server со всеми зависимостями.
|
||
func NewServer(cfg Config) *Server {
|
||
log.Printf("NewServer: storagesvcURL=%q", cfg.StoragesvcURL)
|
||
return &Server{
|
||
dyn: cfg.Dyn,
|
||
kube: cfg.Kube,
|
||
ns: cfg.Namespace,
|
||
routerURL: cfg.RouterURL,
|
||
http: &http.Client{Timeout: cfg.HTTPTimeout},
|
||
saTokenPath: cfg.SATokenPath,
|
||
invokeTimeout: cfg.InvokeTimeout,
|
||
authUser: cfg.AuthUser,
|
||
authPass: cfg.AuthPass,
|
||
storagesvcURL: cfg.StoragesvcURL,
|
||
authenticator: cfg.Authenticator,
|
||
llmURL: cfg.LLMUrl,
|
||
llmKey: cfg.LLMKey,
|
||
nsManager: cloud.NewNSManager(cfg.Dyn),
|
||
billing: cfg.Billing,
|
||
stats: cfg.Stats,
|
||
}
|
||
}
|
||
|
||
// RegisterRoutes регистрирует все HTTP маршруты на mux.
|
||
// Разделено на публичные (без auth) и приватные (с authMiddleware).
|
||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||
// Публичные: health checks, UI, auth endpoint
|
||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/console/", http.StatusTemporaryRedirect)
|
||
})
|
||
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
_, _ = w.Write([]byte("ok\n"))
|
||
})
|
||
mux.HandleFunc("/console/health", func(w http.ResponseWriter, _ *http.Request) {
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
_, _ = w.Write([]byte("ok\n"))
|
||
})
|
||
|
||
// UI: embedded single-page app
|
||
uiHandler := http.StripPrefix("/console", ui.Handler())
|
||
mux.Handle("/console", uiHandler)
|
||
mux.Handle("/console/", uiHandler)
|
||
// Auth: не требует токена — сам проверяет и возвращает namespace
|
||
mux.HandleFunc("/console/api/auth", s.handleAuth)
|
||
|
||
// Cron: публичные маршруты для метрик
|
||
cronHandler := ui.CronHandler()
|
||
mux.Handle("/cron", cronHandler)
|
||
mux.Handle("/cron/", cronHandler)
|
||
mux.Handle("/console/cron", cronHandler)
|
||
mux.Handle("/console/cron/", cronHandler)
|
||
mux.HandleFunc("/cron/api/metrics", s.handleCronMetrics)
|
||
mux.HandleFunc("/console/cron/api/metrics", s.handleCronMetrics)
|
||
|
||
// Приватные: требуют X-Auth-Token / X-Test-Sub
|
||
auth := s.authMiddleware
|
||
|
||
// Legacy /api/* (без /console префикса) — для обратной совместимости
|
||
mux.HandleFunc("/api/environments", s.handleList(fission.EnvironmentGVR))
|
||
mux.HandleFunc("/api/packages", s.handleList(fission.PackageGVR))
|
||
mux.HandleFunc("/api/functions", s.handleFunctionsRoot)
|
||
mux.HandleFunc("/api/functions/", s.handleFunctionsAction)
|
||
mux.HandleFunc("/api/httptriggers", s.handleList(fission.HTTPTrigGVR))
|
||
mux.HandleFunc("/api/timetriggers", s.handleTimeTriggersRoot)
|
||
mux.HandleFunc("/api/timetriggers/", s.handleTimeTriggersAction)
|
||
// Основные /console/api/* маршруты
|
||
mux.HandleFunc("/console/api/environments", auth(s.handleList(fission.EnvironmentGVR)))
|
||
mux.HandleFunc("/console/api/packages", auth(s.handleList(fission.PackageGVR)))
|
||
mux.HandleFunc("/console/api/functions", auth(s.handleFunctionsRoot))
|
||
mux.HandleFunc("/console/api/functions/", auth(s.handleFunctionsAction))
|
||
mux.HandleFunc("/fission-function", s.handleFissionFunctionGateway)
|
||
mux.HandleFunc("/fission-function/", s.handleFissionFunctionGateway)
|
||
mux.HandleFunc("/fn/", auth(s.handleInvokeRoute))
|
||
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
|
||
mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot))
|
||
mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction))
|
||
mux.HandleFunc("/console/api/mqtriggers", auth(s.handleMQTriggersRoot))
|
||
mux.HandleFunc("/console/api/mqtriggers/", auth(s.handleMQTriggersAction))
|
||
mux.HandleFunc("/console/api/kwtriggers", auth(s.handleKWTriggersRoot))
|
||
mux.HandleFunc("/console/api/kwtriggers/", auth(s.handleKWTriggersAction))
|
||
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
|
||
mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug))
|
||
mux.HandleFunc("/console/api/stats/dashboard-url", auth(s.handleStatsDashboard))
|
||
mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck))
|
||
mux.HandleFunc("/console/api/ai/lint-archive", auth(s.handleLintArchive))
|
||
mux.HandleFunc("/console/api/ai/explain-archive", auth(s.handleExplainArchive))
|
||
// --- ai/ask feature (удалить строку чтобы выкосить роут) ---
|
||
mux.HandleFunc("/console/api/ai/ask", auth(s.handleAIAsk))
|
||
// --- end ai/ask feature ---
|
||
}
|
||
|
||
// Handler возвращает http.Handler со всеми middleware: CORS, security headers, логирование.
|
||
func (s *Server) Handler() http.Handler {
|
||
mux := http.NewServeMux()
|
||
s.RegisterRoutes(mux)
|
||
return withSecurityHeaders(withCORS(logRequests(mux)))
|
||
}
|
||
|
||
// NSManager возвращает указатель на NSManager для запуска фоновых горутин из main.
|
||
func (s *Server) NSManager() *cloud.NSManager {
|
||
return s.nsManager
|
||
}
|
||
|
||
// handleList возвращает handler который читает список ресурсов из namespace пользователя.
|
||
// Общий для environments, packages, functions, httptriggers, timetriggers.
|
||
func (s *Server) handleList(gvr schema.GroupVersionResource) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
list, err := s.dyn.Resource(gvr).Namespace(s.userNS(r)).List(ctx, metav1.ListOptions{})
|
||
if err != nil {
|
||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list %s: %v", gvr.Resource, err))
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, list.Items)
|
||
}
|
||
}
|
||
|
||
// writeJSON сериализует список unstructured объектов в JSON response.
|
||
func writeJSON(w http.ResponseWriter, status int, data []unstructured.Unstructured) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(data)
|
||
}
|
||
|
||
// writeAnyJSON сериализует любое значение в JSON response.
|
||
func writeAnyJSON(w http.ResponseWriter, status int, data any) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(data)
|
||
}
|
||
|
||
// writeJSONError возвращает JSON с ключом "error" и заданным HTTP статусом.
|
||
func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"error": msg})
|
||
}
|
||
|
||
// readSAToken читает service account JWT токен из файла.
|
||
// Используется как fallback аутентификация для запросов к Fission router
|
||
// когда authUser/authPass не заданы.
|
||
func (s *Server) readSAToken() string {
|
||
if s.saTokenPath == "" {
|
||
return ""
|
||
}
|
||
data, err := os.ReadFile(s.saTokenPath)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(string(data))
|
||
}
|
||
|
||
// getRouterToken возвращает Bearer токен для запросов к Fission router.
|
||
// Если authUser+authPass заданы — получает JWT через login endpoint (с кэшем).
|
||
// Иначе — использует SA токен из pod filesystem.
|
||
func (s *Server) getRouterToken() string {
|
||
if s.authUser == "" || s.authPass == "" {
|
||
return s.readSAToken()
|
||
}
|
||
|
||
s.tokenMu.Lock()
|
||
defer s.tokenMu.Unlock()
|
||
|
||
if s.cachedJWT != "" && time.Now().Before(s.tokenExpAt) {
|
||
return s.cachedJWT // кэш ещё актуален
|
||
}
|
||
|
||
loginURL := s.routerURL + "/auth/login"
|
||
body, _ := json.Marshal(map[string]string{"username": s.authUser, "password": s.authPass})
|
||
resp, err := s.http.Post(loginURL, "application/json", bytes.NewReader(body))
|
||
if err != nil {
|
||
return s.readSAToken()
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||
return s.readSAToken()
|
||
}
|
||
|
||
var result struct {
|
||
AccessToken string `json:"accesstoken"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || result.AccessToken == "" {
|
||
return s.readSAToken()
|
||
}
|
||
|
||
s.cachedJWT = result.AccessToken
|
||
// Fission JWT живёт ~120s; кэшируем на 100s чтобы не использовать просроченный
|
||
s.tokenExpAt = time.Now().Add(100 * time.Second)
|
||
return s.cachedJWT
|
||
}
|