refactor: split main.go into packages (internal/api, internal/fission, internal/runtime, cmd/server)
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o fission-console .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o fission-console ./cmd/server/
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates nodejs python3 ruby perl php83
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// cmd/server/main.go — точка входа fission-console сервера.
|
||||
// Отвечает только за: парсинг env vars, создание зависимостей, запуск HTTP сервера.
|
||||
// Вся бизнес-логика находится в internal/api/, internal/fission/, internal/runtime/.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fission-console/internal/api"
|
||||
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
kubeconfig := strings.TrimSpace(os.Getenv("KUBECONFIG"))
|
||||
namespace := envDefault("FISSION_NAMESPACE", "default")
|
||||
routerURL := strings.TrimRight(envDefault("FISSION_ROUTER_URL", "http://router.fission.svc.cluster.local"), "/")
|
||||
port := envDefault("PORT", "8090")
|
||||
httpTimeout := envDurationDefault("FISSION_HTTP_TIMEOUT", 30*time.Second)
|
||||
invokeTimeout := envDurationDefault("FISSION_INVOKE_TIMEOUT", 20*time.Second)
|
||||
|
||||
// Строим kubernetes config: сначала kubeconfig файл, потом in-cluster, потом default
|
||||
cfg, err := buildConfig(kubeconfig)
|
||||
if err != nil {
|
||||
log.Fatalf("build kube config: %v", err)
|
||||
}
|
||||
|
||||
dyn, err := dynamic.NewForConfig(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("create dynamic client: %v", err)
|
||||
}
|
||||
|
||||
srv := api.NewServer(api.Config{
|
||||
Dyn: dyn,
|
||||
Namespace: namespace,
|
||||
RouterURL: routerURL,
|
||||
HTTPTimeout: httpTimeout,
|
||||
InvokeTimeout: invokeTimeout,
|
||||
SATokenPath: envDefault("SA_TOKEN_PATH", "/var/run/secrets/kubernetes.io/serviceaccount/token"),
|
||||
AuthUser: envDefault("FISSION_AUTH_USERNAME", ""),
|
||||
AuthPass: envDefault("FISSION_AUTH_PASSWORD", ""),
|
||||
TestMode: os.Getenv("FISSION_TEST_MODE") == "true",
|
||||
// --- ai/ask feature ---
|
||||
LLMUrl: envDefault("FISSION_LLM_URL", "https://api.aillm.ru"),
|
||||
LLMKey: os.Getenv("FISSION_LLM_KEY"),
|
||||
// --- end ai/ask feature ---
|
||||
})
|
||||
|
||||
// Запускаем фоновые горутины: reaper истёкших функций + reconciler namespace-ов
|
||||
nsm := srv.NSManager()
|
||||
nsm.StartExpiryReaper(envDurationDefault("REAPER_INTERVAL", 5*time.Minute))
|
||||
nsm.StartNSReconciler(envDurationDefault("NS_RECONCILE_INTERVAL", 2*time.Minute))
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: srv.Handler(),
|
||||
// ReadHeaderTimeout защищает от Slowloris атаки (медленная отправка заголовков)
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("fission-console listening on :%s (namespace=%s)", port, namespace)
|
||||
log.Fatal(httpServer.ListenAndServe())
|
||||
}
|
||||
|
||||
// buildConfig строит kubernetes rest.Config в порядке приоритета:
|
||||
// 1. KUBECONFIG env var или флаг — для локальной разработки
|
||||
// 2. In-cluster config — для запуска внутри pod
|
||||
// 3. Default kubeconfig — ~/.kube/config
|
||||
func buildConfig(kubeconfig string) (*rest.Config, error) {
|
||||
if kubeconfig != "" {
|
||||
return clientcmd.BuildConfigFromFlags("", kubeconfig)
|
||||
}
|
||||
cfg, err := rest.InClusterConfig()
|
||||
if err == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
// Fallback: пробуем default kubeconfig (empty rules → ~/.kube/config)
|
||||
loadingRules := &clientcmd.ClientConfigLoadingRules{}
|
||||
clientCfg := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{})
|
||||
return clientCfg.ClientConfig()
|
||||
}
|
||||
|
||||
func envDefault(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envDurationDefault(key string, fallback time.Duration) time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil || d <= 0 {
|
||||
log.Printf("invalid duration for %s=%q, using default %s", key, raw, fallback)
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleAICheck проверяет синтаксис кода через реальный линтер языка.
|
||||
// POST /console/api/ai/check
|
||||
// Body: {"language":"python","code":"..."}
|
||||
// Response: {"ok":true,"result":"..."}
|
||||
//
|
||||
// Почему реальные линтеры, а не LLM:
|
||||
// LLM часто "исправляет" валидный код и врёт о наличии ошибок.
|
||||
// node --check / python3 -m py_compile / ruby -c / php -l / perl -c — детерминированы и надёжны.
|
||||
// Go использует go/parser прямо в процессе — без внешних команд, быстро.
|
||||
func (s *Server) handleAICheck(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Language string `json:"language"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Code) == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"ok": false, "result": "Код пустой."})
|
||||
return
|
||||
}
|
||||
|
||||
// Конфигурация линтера для каждого языка
|
||||
type linterCfg struct {
|
||||
ext string
|
||||
cmd []string
|
||||
}
|
||||
langs := map[string]linterCfg{
|
||||
"nodejs": {ext: ".js", cmd: []string{"node", "--check"}},
|
||||
"python": {ext: ".py", cmd: []string{"python3", "-m", "py_compile"}},
|
||||
"ruby": {ext: ".rb", cmd: []string{"ruby", "-c"}},
|
||||
"php": {ext: ".php", cmd: []string{"php", "-l"}},
|
||||
"perl": {ext: ".pl", cmd: []string{"perl", "-c"}},
|
||||
"go": {ext: ".go", cmd: nil}, // go проверяется через go/parser в процессе
|
||||
}
|
||||
|
||||
cfg, ok := langs[req.Language]
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusBadRequest, "unsupported language: "+req.Language)
|
||||
return
|
||||
}
|
||||
|
||||
var isOK bool
|
||||
var result string
|
||||
|
||||
if req.Language == "go" {
|
||||
// Go: используем go/parser прямо в процессе — без внешних команд и temp файлов
|
||||
fset := token.NewFileSet()
|
||||
_, parseErr := parser.ParseFile(fset, "code.go", req.Code, parser.AllErrors)
|
||||
if parseErr == nil {
|
||||
isOK = true
|
||||
result = "✅ Синтаксис корректен."
|
||||
} else {
|
||||
isOK = false
|
||||
result = parseErr.Error()
|
||||
}
|
||||
} else {
|
||||
// Записываем код во временный файл с нужным расширением
|
||||
// (линтеры ruby/php определяют режим проверки по расширению)
|
||||
tmpf, err := os.CreateTemp("", "fission-lint-*"+cfg.ext)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "tmp file: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer os.Remove(tmpf.Name()) // очищаем независимо от результата
|
||||
|
||||
if _, err := tmpf.WriteString(req.Code); err != nil {
|
||||
tmpf.Close()
|
||||
writeJSONError(w, http.StatusInternalServerError, "write tmp: "+err.Error())
|
||||
return
|
||||
}
|
||||
tmpf.Close()
|
||||
|
||||
args := append(cfg.cmd, tmpf.Name())
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
//nolint:gosec — cfg.cmd содержит только захардкоженные команды из langs (не user input)
|
||||
out, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput()
|
||||
outStr := strings.TrimSpace(string(out))
|
||||
// Убираем путь к tmp-файлу из вывода — пользователю незачем его видеть
|
||||
outStr = strings.ReplaceAll(outStr, tmpf.Name(), "<code>")
|
||||
|
||||
if err == nil {
|
||||
isOK = true
|
||||
result = "✅ Синтаксис корректен."
|
||||
} else {
|
||||
isOK = false
|
||||
if outStr != "" {
|
||||
result = outStr
|
||||
} else {
|
||||
result = "Ошибка синтаксиса (линтер вернул код " + fmt.Sprintf("%v", err) + ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": isOK,
|
||||
"result": result,
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ai/ask feature — удалить весь блок до "end ai/ask feature" чтобы выкосить
|
||||
// =============================================================================
|
||||
|
||||
// handleAIAsk отвечает на произвольный вопрос пользователя через LLM.
|
||||
// POST /console/api/ai/ask
|
||||
// Body: {"question":"..."}
|
||||
// Response: {"answer":"..."}
|
||||
func (s *Server) handleAIAsk(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
if s.llmKey == "" {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "AI-ассистент не настроен (FISSION_LLM_KEY не задан)")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Question string `json:"question"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4*1024)).Decode(&req); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Question) == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "question is required")
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"model": "gpt-oss-120b",
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": "Ты умный ассистент. Отвечай кратко и по делу."},
|
||||
{"role": "user", "content": req.Question},
|
||||
},
|
||||
"max_tokens": 1024,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
llmReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
strings.TrimRight(s.llmURL, "/")+"/chat/completions",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
llmReq.Header.Set("Content-Type", "application/json")
|
||||
llmReq.Header.Set("Authorization", "Bearer "+s.llmKey)
|
||||
|
||||
resp, err := s.http.Do(llmReq)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusBadGateway, "LLM недоступен: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var llmResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&llmResp); err != nil {
|
||||
writeJSONError(w, http.StatusBadGateway, "parse error: "+err.Error())
|
||||
return
|
||||
}
|
||||
if llmResp.Error != nil {
|
||||
writeJSONError(w, http.StatusBadGateway, llmResp.Error.Message)
|
||||
return
|
||||
}
|
||||
if len(llmResp.Choices) == 0 {
|
||||
writeJSONError(w, http.StatusBadGateway, "empty response from LLM")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"answer": strings.TrimSpace(llmResp.Choices[0].Message.Content),
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// end ai/ask feature
|
||||
// =============================================================================
|
||||
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ctxKeyNS — ключ для хранения namespace пользователя в context.Context.
|
||||
// Использует приватный тип чтобы избежать коллизий с ключами из других пакетов.
|
||||
type ctxKeyNS struct{}
|
||||
|
||||
// userNS возвращает namespace пользователя из контекста запроса.
|
||||
// Устанавливается в authMiddleware после успешной аутентификации.
|
||||
func (s *Server) userNS(r *http.Request) string {
|
||||
if ns, ok := r.Context().Value(ctxKeyNS{}).(string); ok && ns != "" {
|
||||
return ns
|
||||
}
|
||||
// Fallback: использовать системный namespace (не должно происходить в prod)
|
||||
return s.ns
|
||||
}
|
||||
|
||||
// authMiddleware оборачивает handler, добавляя аутентификацию и инициализацию namespace.
|
||||
//
|
||||
// В testMode (FISSION_TEST_MODE=true):
|
||||
// - Deck API не вызывается
|
||||
// - X-Test-Sub или X-Auth-Token задают sub → разные namespace-ы для тестирования
|
||||
//
|
||||
// В production:
|
||||
// - X-Auth-Token валидируется через Deck API
|
||||
// - Namespace вычисляется из JWT claim "sub"
|
||||
func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var ns string
|
||||
|
||||
if s.testMode {
|
||||
sub := strings.TrimSpace(r.Header.Get("X-Test-Sub"))
|
||||
if sub == "" {
|
||||
sub = strings.TrimSpace(r.Header.Get("X-Auth-Token"))
|
||||
}
|
||||
if sub == "" {
|
||||
writeJSONError(w, http.StatusUnauthorized, "test mode: X-Test-Sub required")
|
||||
return
|
||||
}
|
||||
h32 := sha256.Sum256([]byte(sub))
|
||||
ns = "fission-" + hex.EncodeToString(h32[:8])
|
||||
} else {
|
||||
token := strings.TrimSpace(r.Header.Get("X-Auth-Token"))
|
||||
env := strings.TrimSpace(strings.ToLower(r.Header.Get("X-Auth-Env")))
|
||||
if _, ok := deckAPIs[env]; !ok {
|
||||
env = "test"
|
||||
}
|
||||
if token == "" {
|
||||
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
if err := s.validateDeckToken(token, env); err != nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
ns, err = namespaceFromJWT(token)
|
||||
if err != nil {
|
||||
ns = s.ns
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ctxKeyNS{}, ns)
|
||||
|
||||
// Гарантируем что namespace + RBAC + quota + netpol существуют.
|
||||
// EnsureUserNS реализует singleflight + кэш + семафор параллелизма.
|
||||
if ensureErr := s.nsManager.EnsureUserNS(ctx, ns); ensureErr != nil {
|
||||
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", ensureErr))
|
||||
return
|
||||
}
|
||||
|
||||
h(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
// namespaceFromJWT декодирует JWT payload (без верификации подписи),
|
||||
// извлекает claim "sub" и вычисляет namespace: "fission-" + hex(SHA256(sub)[:8]).
|
||||
//
|
||||
// Подпись не проверяется — токен уже валидирован через Deck API (validateDeckToken).
|
||||
// Здесь нам нужен только deterministic namespace name из sub claim.
|
||||
func namespaceFromJWT(token string) (string, error) {
|
||||
parts := strings.SplitN(token, ".", 3)
|
||||
if len(parts) != 3 {
|
||||
return "", fmt.Errorf("invalid JWT format")
|
||||
}
|
||||
payload := parts[1]
|
||||
|
||||
// JWT использует base64url без padding — добавляем если нужно
|
||||
switch len(payload) % 4 {
|
||||
case 2:
|
||||
payload += "=="
|
||||
case 3:
|
||||
payload += "="
|
||||
}
|
||||
|
||||
// base64url без стандартного padding — пробуем оба варианта
|
||||
decoded, err := base64.URLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
decoded, err = base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode JWT payload: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return "", fmt.Errorf("unmarshal JWT claims: %w", err)
|
||||
}
|
||||
sub, _ := claims["sub"].(string)
|
||||
if sub == "" {
|
||||
return "", fmt.Errorf("JWT missing sub claim")
|
||||
}
|
||||
h := sha256.Sum256([]byte(sub))
|
||||
return "fission-" + hex.EncodeToString(h[:8]), nil
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package api содержит HTTP сервер, роутинг, handlers и middleware.
|
||||
package api
|
||||
@@ -0,0 +1,739 @@
|
||||
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
|
||||
|
||||
// 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 {
|
||||
var deployBytes []byte
|
||||
if req.Language == "nodejs" {
|
||||
zipBytes, zipErr := runtime.BuildJSDeployZip(req.Code)
|
||||
if zipErr != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build nodejs archive: %v", zipErr))
|
||||
return
|
||||
}
|
||||
deployBytes = zipBytes
|
||||
} else {
|
||||
// Python, PHP, Ruby, Perl — код передаётся как есть в deployment.literal
|
||||
deployBytes = []byte(req.Code)
|
||||
}
|
||||
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")
|
||||
var deployBytes []byte
|
||||
if lang == "nodejs" {
|
||||
zipBytes, zipErr := runtime.BuildJSDeployZip(req.Code)
|
||||
if zipErr != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build nodejs archive: %v", zipErr))
|
||||
return
|
||||
}
|
||||
deployBytes = zipBytes
|
||||
} else {
|
||||
deployBytes = []byte(req.Code)
|
||||
}
|
||||
|
||||
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: батчит изменения без race condition и rolling restarts
|
||||
select {
|
||||
case s.nsManager.NSReconcileCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// logRequests логирует метод и путь каждого входящего запроса.
|
||||
// Middleware-обёртка: оборачивает любой http.Handler.
|
||||
func logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("%s %s", r.Method, r.URL.Path)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// withCORS добавляет CORS заголовки для разрешения cross-origin запросов.
|
||||
//
|
||||
// Почему Access-Control-Allow-Origin: *:
|
||||
// Console — одностраничное приложение, может обслуживаться с любого домена.
|
||||
// Строгий CORS с конкретным Origin потребовал бы конфигурации при деплое.
|
||||
// Все чувствительные операции защищены X-Auth-Token — не cookie, не ambient credentials.
|
||||
func withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Auth-Token, X-Auth-Env")
|
||||
// Preflight OPTIONS — отвечаем сразу без передачи в handler
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// withSecurityHeaders добавляет стандартные HTTP security заголовки.
|
||||
// Снижает риск XSS, clickjacking и утечки Referer.
|
||||
func withSecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// nosniff: браузер не должен угадывать MIME тип — только то что в Content-Type
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
// DENY: запрещаем отображение страницы в iframe (clickjacking protection)
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
// strict-origin-when-cross-origin: скрываем полный URL при cross-origin запросах
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
// Отключаем браузерные API которые не нужны console
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
// CSP: разрешаем только ресурсы с того же origin + inline scripts/styles (нужны для embedded UI)
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' data:; connect-src 'self'; font-src 'self' data:; object-src 'none'; "+
|
||||
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fission-console/internal/fission"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
// handleNSStatus возвращает статус инициализации пользовательского namespace.
|
||||
// Используется UI для отображения прогресса при первом входе нового пользователя.
|
||||
//
|
||||
// Три стадии:
|
||||
// 1. Namespace существует и Active
|
||||
// 2. Namespace добавлен в FISSION_RESOURCE_NAMESPACES executor deployment
|
||||
// 3. Executor pod Running + Ready (готов принимать функции)
|
||||
func (s *Server) handleNSStatus(w http.ResponseWriter, r *http.Request) {
|
||||
ns := s.userNS(r)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fissionNS := os.Getenv("FISSION_SYSTEM_NAMESPACE")
|
||||
if fissionNS == "" {
|
||||
fissionNS = "fission"
|
||||
}
|
||||
|
||||
type stageInfo struct {
|
||||
Name string `json:"name"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
stages := []stageInfo{
|
||||
{Name: "Создание пространства имён"},
|
||||
{Name: "Регистрация в Fission"},
|
||||
{Name: "Прогрев окружений"},
|
||||
}
|
||||
|
||||
// Stage 1: namespace существует и Active
|
||||
nsObj, err := s.dyn.Resource(fission.NamespaceGVR).Get(ctx, ns, metav1.GetOptions{})
|
||||
if err == nil {
|
||||
phase, _, _ := unstructured.NestedString(nsObj.Object, "status", "phase")
|
||||
stages[0].Done = phase == "Active"
|
||||
}
|
||||
|
||||
// Stage 2: namespace в FISSION_RESOURCE_NAMESPACES executor deployment
|
||||
if stages[0].Done {
|
||||
execDep, err2 := s.dyn.Resource(fission.DeploymentGVR).Namespace(fissionNS).Get(ctx, "executor", metav1.GetOptions{})
|
||||
if err2 == nil {
|
||||
stages[1].Done = nsInFissionEnv(execDep, ns)
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 3: executor pod Running + Ready
|
||||
if stages[1].Done {
|
||||
podGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}
|
||||
podList, err3 := s.dyn.Resource(podGVR).Namespace(fissionNS).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: "svc=executor",
|
||||
})
|
||||
if err3 == nil {
|
||||
for _, pod := range podList.Items {
|
||||
phase, _, _ := unstructured.NestedString(pod.Object, "status", "phase")
|
||||
if phase != "Running" {
|
||||
continue
|
||||
}
|
||||
conditions, _, _ := unstructured.NestedSlice(pod.Object, "status", "conditions")
|
||||
for _, c := range conditions {
|
||||
cond, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if cond["type"] == "Ready" && cond["status"] == "True" {
|
||||
stages[2].Done = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ready := stages[0].Done && stages[1].Done && stages[2].Done
|
||||
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||
"ready": ready,
|
||||
"stages": stages,
|
||||
})
|
||||
}
|
||||
|
||||
// nsInFissionEnv проверяет что namespace ns содержится в FISSION_RESOURCE_NAMESPACES
|
||||
// первого контейнера данного deployment.
|
||||
func nsInFissionEnv(dep *unstructured.Unstructured, ns string) bool {
|
||||
containers, _, _ := unstructured.NestedSlice(dep.Object, "spec", "template", "spec", "containers")
|
||||
for _, c := range containers {
|
||||
cont, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
envs, _, _ := unstructured.NestedSlice(cont, "env")
|
||||
for _, e := range envs {
|
||||
env, ok := e.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if env["name"] == "FISSION_RESOURCE_NAMESPACES" {
|
||||
if v, ok := env["value"].(string); ok {
|
||||
for _, p := range strings.Split(v, ",") {
|
||||
if strings.TrimSpace(p) == ns {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break // только первый контейнер
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
// extractPackageSourceCode пытается извлечь исходный код из Fission Package.
|
||||
// Порядок попыток:
|
||||
// 1. spec.source.literal (base64) — Go функции (source package)
|
||||
// 2. spec.deployment.literal (base64) — интерпретируемые языки
|
||||
// 3. spec.source.url / spec.deployment.url — скачиваем архив по URL
|
||||
//
|
||||
// Декодирование: если данные — валидный UTF-8, возвращаем как есть.
|
||||
// Если это zip — ищем известные файлы (main.py, handler.rb и т.д.).
|
||||
func extractPackageSourceCode(ctx context.Context, s *Server, pkg *unstructured.Unstructured) string {
|
||||
literalPaths := [][]string{
|
||||
{"spec", "source", "literal"},
|
||||
{"spec", "deployment", "literal"},
|
||||
}
|
||||
for _, p := range literalPaths {
|
||||
literal, found, _ := unstructured.NestedString(pkg.Object, p...)
|
||||
if !found || strings.TrimSpace(literal) == "" {
|
||||
continue
|
||||
}
|
||||
if code, err := decodeLiteralToSource(literal); err == nil && strings.TrimSpace(code) != "" {
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: скачиваем по URL
|
||||
urlPaths := [][]string{
|
||||
{"spec", "source", "url"},
|
||||
{"spec", "deployment", "url"},
|
||||
}
|
||||
for _, p := range urlPaths {
|
||||
urlValue, found, _ := unstructured.NestedString(pkg.Object, p...)
|
||||
if !found || strings.TrimSpace(urlValue) == "" {
|
||||
continue
|
||||
}
|
||||
archiveBytes, err := fetchPackageArchive(ctx, s, urlValue)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if code, err := decodeArchiveBytesToSource(archiveBytes); err == nil && strings.TrimSpace(code) != "" {
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// fetchPackageArchive скачивает архив функции по URL из Fission storage.
|
||||
func fetchPackageArchive(ctx context.Context, s *Server, archiveURL string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// decodeLiteralToSource декодирует base64-строку из Package.spec.*.literal в исходный код.
|
||||
func decodeLiteralToSource(literal string) (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(literal)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return decodeArchiveBytesToSource(decoded)
|
||||
}
|
||||
|
||||
// decodeArchiveBytesToSource преобразует байты (utf-8 строка или zip архив) в исходный код.
|
||||
func decodeArchiveBytesToSource(decoded []byte) (string, error) {
|
||||
if len(decoded) == 0 {
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
// Если байты — валидный UTF-8, возвращаем напрямую (python, ruby, perl, php)
|
||||
if utf8.Valid(decoded) {
|
||||
return string(decoded), nil
|
||||
}
|
||||
// PK signature: zip архив (nodejs, go source)
|
||||
if len(decoded) >= 4 && bytes.Equal(decoded[:4], []byte{'P', 'K', 3, 4}) {
|
||||
if src, zipErr := decodeZipSource(decoded); zipErr == nil {
|
||||
return src, nil
|
||||
}
|
||||
}
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
// decodeZipSource извлекает исходный UTF-8 файл из zip архива.
|
||||
// Предпочитает хорошо известные имена файлов (main.py, handler.go и т.д.).
|
||||
func decodeZipSource(zipBytes []byte) (string, error) {
|
||||
reader, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Сначала ищем по приоритетным именам
|
||||
preferred := []string{"main.py", "main.js", "main.go", "handler.go", "handler.js", "handler.py"}
|
||||
for _, name := range preferred {
|
||||
for _, file := range reader.File {
|
||||
if strings.EqualFold(file.Name, name) {
|
||||
content, readErr := readZipFile(file)
|
||||
if readErr == nil && utf8.Valid(content) {
|
||||
return string(content), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: первый UTF-8 файл по алфавиту (не директория)
|
||||
files := make([]*zip.File, 0, len(reader.File))
|
||||
for _, file := range reader.File {
|
||||
if !file.FileInfo().IsDir() {
|
||||
files = append(files, file)
|
||||
}
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
|
||||
|
||||
for _, file := range files {
|
||||
content, readErr := readZipFile(file)
|
||||
if readErr == nil && utf8.Valid(content) {
|
||||
return string(content), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
// readZipFile открывает и читает содержимое файла из zip архива.
|
||||
func readZipFile(file *zip.File) ([]byte, error) {
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// decodeZipSource извлекает исходный UTF-8 файл из zip архива.
|
||||
@@ -0,0 +1,295 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fission-console/internal/fission"
|
||||
"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"
|
||||
)
|
||||
|
||||
// deckAPIs — карта окружений Deck API.
|
||||
// Ключ используется в X-Auth-Env заголовке для выбора нужного сервера.
|
||||
var deckAPIs = map[string]string{
|
||||
"prod": "https://deck-api.ngcloud.ru/api/v1",
|
||||
"dev": "https://deck-api-dev.ngcloud.ru/api/v1",
|
||||
"test": "https://deck-api-test.ngcloud.ru/api/v1",
|
||||
}
|
||||
|
||||
// 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
|
||||
ns string // системный namespace (fallback, обычно "fission")
|
||||
routerURL string
|
||||
http *http.Client
|
||||
|
||||
saTokenPath string
|
||||
invokeTimeout time.Duration
|
||||
testMode bool // FISSION_TEST_MODE=true — пропускает deck auth
|
||||
|
||||
authUser string
|
||||
authPass string
|
||||
|
||||
// --- 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
|
||||
|
||||
// tokenCache кэширует результаты валидации Deck токенов.
|
||||
// Ключ: "env:token", значение: time.Time — когда кэш истекает.
|
||||
tokenCache sync.Map
|
||||
|
||||
// nsManager управляет жизненным циклом пользовательских namespace-ов.
|
||||
nsManager *fission.NSManager
|
||||
}
|
||||
|
||||
// Config содержит все параметры для создания Server.
|
||||
type Config struct {
|
||||
Dyn dynamic.Interface
|
||||
Namespace string
|
||||
RouterURL string
|
||||
HTTPTimeout time.Duration
|
||||
InvokeTimeout time.Duration
|
||||
SATokenPath string
|
||||
AuthUser string
|
||||
AuthPass string
|
||||
TestMode bool
|
||||
LLMUrl string
|
||||
LLMKey string
|
||||
}
|
||||
|
||||
// NewServer создаёт и настраивает HTTP Server со всеми зависимостями.
|
||||
func NewServer(cfg Config) *Server {
|
||||
return &Server{
|
||||
dyn: cfg.Dyn,
|
||||
ns: cfg.Namespace,
|
||||
routerURL: cfg.RouterURL,
|
||||
http: &http.Client{Timeout: cfg.HTTPTimeout},
|
||||
saTokenPath: cfg.SATokenPath,
|
||||
invokeTimeout: cfg.InvokeTimeout,
|
||||
authUser: cfg.AuthUser,
|
||||
authPass: cfg.AuthPass,
|
||||
testMode: cfg.TestMode,
|
||||
llmURL: cfg.LLMUrl,
|
||||
llmKey: cfg.LLMKey,
|
||||
nsManager: fission.NewNSManager(cfg.Dyn),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes регистрирует все HTTP маршруты на mux.
|
||||
// Разделено на публичные (без auth) и приватные (с authMiddleware).
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
// Публичные: health checks, UI, auth endpoint
|
||||
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)
|
||||
|
||||
// Приватные: требуют 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.handleList(fission.TimeTrigGVR))
|
||||
|
||||
// Основные /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("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
|
||||
mux.HandleFunc("/console/api/timetriggers", auth(s.handleList(fission.TimeTrigGVR)))
|
||||
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
|
||||
mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck))
|
||||
// --- 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() *fission.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
|
||||
}
|
||||
|
||||
// validateDeckToken проверяет токен через Deck API с кэшированием результата на 5 минут.
|
||||
func (s *Server) validateDeckToken(token, env string) error {
|
||||
cacheKey := env + ":" + token
|
||||
if v, ok := s.tokenCache.Load(cacheKey); ok {
|
||||
if time.Now().Before(v.(time.Time)) {
|
||||
return nil // кэш актуален — токен валиден
|
||||
}
|
||||
s.tokenCache.Delete(cacheKey)
|
||||
}
|
||||
|
||||
apiBase, ok := deckAPIs[env]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown deck env: %s", env)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+"/index.cfm/instances", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := s.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// Кэшируем успешный результат
|
||||
s.tokenCache.Store(cacheKey, time.Now().Add(5*time.Minute))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Package fission содержит константы GVR и вспомогательные функции
|
||||
// для работы с Fission CRDs через Kubernetes dynamic client.
|
||||
package fission
|
||||
|
||||
import "k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
// GroupVersionResource константы для всех Fission CRD и core K8s ресурсов.
|
||||
// Вынесены сюда чтобы не дублировать между пакетами и легко обновить при смене версии API.
|
||||
var (
|
||||
EnvironmentGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "environments"}
|
||||
PackageGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "packages"}
|
||||
FunctionGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
|
||||
HTTPTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"}
|
||||
TimeTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"}
|
||||
|
||||
NamespaceGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}
|
||||
DeploymentGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
package fission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"fission-console/internal/model"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/dynamic"
|
||||
)
|
||||
|
||||
// EnsureEnvironment создаёт Environment CRD для языка lang в namespace ns если не существует.
|
||||
// Возвращает имя environment (например "console-python-env").
|
||||
//
|
||||
// Lazy creation: environments создаются только когда пользователь создаёт первую функцию
|
||||
// на конкретном языке. Это экономит ресурсы — Fission pool pods поднимаются только
|
||||
// под те языки которые реально используются, а не все сразу при создании namespace.
|
||||
func EnsureEnvironment(ctx context.Context, dyn dynamic.Interface, ns, lang string) (string, error) {
|
||||
langDef, ok := model.LangEnvMap[lang]
|
||||
if !ok {
|
||||
// Неизвестный язык — клиентская ошибка, возвращаем 400-совместимое сообщение
|
||||
return "", fmt.Errorf("unsupported language: %q", lang)
|
||||
}
|
||||
envName := "console-" + lang + "-env"
|
||||
|
||||
// Проверяем существование — Get быстрее чем Create+IsAlreadyExists
|
||||
_, getErr := dyn.Resource(EnvironmentGVR).Namespace(ns).Get(ctx, envName, metav1.GetOptions{})
|
||||
if getErr == nil {
|
||||
return envName, nil // уже существует — быстрый путь
|
||||
}
|
||||
if !apierrors.IsNotFound(getErr) {
|
||||
return "", fmt.Errorf("check environment %q: %w", envName, getErr)
|
||||
}
|
||||
|
||||
// Строим и создаём Environment CRD
|
||||
env := buildLangEnvironment(envName, ns, langDef)
|
||||
if _, createErr := dyn.Resource(EnvironmentGVR).Namespace(ns).Create(ctx, env, metav1.CreateOptions{}); createErr != nil {
|
||||
if apierrors.IsAlreadyExists(createErr) {
|
||||
// Race condition: другой goroutine создал между Get и Create — это нормально
|
||||
return envName, nil
|
||||
}
|
||||
return "", fmt.Errorf("create environment %q: %w", envName, createErr)
|
||||
}
|
||||
log.Printf("ensureEnvironment: created %s/%s", ns, envName)
|
||||
return envName, nil
|
||||
}
|
||||
|
||||
// CleanupEnvironmentIfUnused удаляет Environment CRD если ни одна функция в namespace
|
||||
// его не использует. Вызывается после удаления функции (handleDeleteFunction)
|
||||
// и при срабатывании reaper-а (runExpiryReap).
|
||||
//
|
||||
// Не блокирующий: ошибки логируются, не возвращаются вызывающему — это best-effort cleanup.
|
||||
// Fission увидит удаление Environment CRD и убьёт pool Deployment → поды умирают.
|
||||
func CleanupEnvironmentIfUnused(ctx context.Context, dyn dynamic.Interface, ns, envName string) {
|
||||
functions, err := dyn.Resource(FunctionGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
log.Printf("cleanupEnvironmentIfUnused: list functions in %s: %v", ns, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Если хотя бы одна функция ссылается на этот environment — оставляем его
|
||||
for _, fn := range functions.Items {
|
||||
fnEnv, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
||||
if fnEnv == envName {
|
||||
return // ещё используется
|
||||
}
|
||||
}
|
||||
|
||||
// Ни одна функция не ссылается — удаляем
|
||||
if delErr := dyn.Resource(EnvironmentGVR).Namespace(ns).Delete(ctx, envName, metav1.DeleteOptions{}); delErr != nil && !apierrors.IsNotFound(delErr) {
|
||||
log.Printf("cleanupEnvironmentIfUnused: delete env %s/%s: %v", ns, envName, delErr)
|
||||
return
|
||||
}
|
||||
log.Printf("cleanupEnvironmentIfUnused: deleted unused env %s/%s", ns, envName)
|
||||
}
|
||||
|
||||
// buildLangEnvironment строит unstructured.Unstructured объект Environment CRD.
|
||||
// Вся Fission-специфичная схема изолирована здесь — при обновлении Fission меняем только тут.
|
||||
func buildLangEnvironment(name, ns string, def model.LangEnvDef) *unstructured.Unstructured {
|
||||
// Version по умолчанию 3 (V2 protocol с async entrypoint).
|
||||
// Исключение: perl-env поддерживает только V1 protocol → version=1.
|
||||
envVersion := int64(3)
|
||||
if def.Version != 0 {
|
||||
envVersion = int64(def.Version)
|
||||
}
|
||||
|
||||
spec := map[string]any{
|
||||
"version": envVersion,
|
||||
"runtime": map[string]any{
|
||||
"image": def.Image,
|
||||
},
|
||||
"poolsize": int64(1),
|
||||
}
|
||||
|
||||
// BuilderImage задан только для Go — остальные языки интерпретируемые,
|
||||
// им builder не нужен: код передаётся напрямую в deployment.literal.
|
||||
if def.BuilderImage != "" {
|
||||
spec["builder"] = map[string]any{
|
||||
"image": def.BuilderImage,
|
||||
"command": "build",
|
||||
}
|
||||
}
|
||||
|
||||
return &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "fission.io/v1",
|
||||
"kind": "Environment",
|
||||
"metadata": map[string]any{
|
||||
"name": name,
|
||||
"namespace": ns,
|
||||
},
|
||||
"spec": spec,
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
package fission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/dynamic"
|
||||
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// NSInflightEnsure — состояние in-flight вызова EnsureUserNamespace.
|
||||
// Все параллельные горутины ждут close(done), затем читают err.
|
||||
// Реализует ручной singleflight без внешних зависимостей.
|
||||
type NSInflightEnsure struct {
|
||||
done chan struct{}
|
||||
err error
|
||||
}
|
||||
|
||||
// NSManager управляет жизненным циклом пользовательских namespace-ов:
|
||||
// создание, RBAC, quota, network policies, reconciler, reaper.
|
||||
type NSManager struct {
|
||||
dyn dynamic.Interface
|
||||
|
||||
// nsReconcileCh — сигнал для немедленного запуска NS reconciler.
|
||||
// Буферизирован на 1: несколько сигналов схлопываются в один запуск.
|
||||
NSReconcileCh chan struct{}
|
||||
|
||||
// Singleflight + кэш для ensureUserNamespace:
|
||||
// ensuredNS — namespace-ы которые уже инициализированы в этом запуске процесса.
|
||||
// ensuredNSInFlight — текущие in-flight операции (ключ = namespace name).
|
||||
ensuredNSMu sync.Mutex
|
||||
ensuredNS map[string]struct{}
|
||||
ensuredNSInFlight map[string]*NSInflightEnsure
|
||||
|
||||
// nsSemaphore ограничивает параллелизм EnsureUserNamespace — не более 3 одновременно.
|
||||
// Без него 10 новых пользователей генерируют 140 K8s API calls одновременно → throttle → 504.
|
||||
// С семафором: 3 batch-а по 14 calls → ~3 × 5s = 15s total, все укладываются в timeout.
|
||||
nsSemaphore chan struct{}
|
||||
}
|
||||
|
||||
// NewNSManager создаёт NSManager с инициализированными каналами и структурами.
|
||||
func NewNSManager(dyn dynamic.Interface) *NSManager {
|
||||
return &NSManager{
|
||||
dyn: dyn,
|
||||
NSReconcileCh: make(chan struct{}, 1),
|
||||
ensuredNS: make(map[string]struct{}),
|
||||
ensuredNSInFlight: make(map[string]*NSInflightEnsure),
|
||||
nsSemaphore: make(chan struct{}, 3),
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureUserNS — единая точка входа для гарантии существования пользовательского namespace.
|
||||
// Реализует singleflight + in-memory кэш + семафор параллелизма.
|
||||
//
|
||||
// Singleflight: если один goroutine уже создаёт namespace ns — остальные ждут его результата
|
||||
// вместо того чтобы запускать параллельные K8s API calls (вызывало throttle и 504).
|
||||
//
|
||||
// Кэш: если namespace уже создан в этом запуске процесса — быстрый путь без K8s calls.
|
||||
func (m *NSManager) EnsureUserNS(ctx context.Context, ns string) error {
|
||||
m.ensuredNSMu.Lock()
|
||||
if _, ok := m.ensuredNS[ns]; ok {
|
||||
// Быстрый путь: уже создан в этой жизни процесса.
|
||||
m.ensuredNSMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if inflight, ok := m.ensuredNSInFlight[ns]; ok {
|
||||
// Кто-то уже создаёт — ждём его результата (singleflight).
|
||||
m.ensuredNSMu.Unlock()
|
||||
select {
|
||||
case <-inflight.done:
|
||||
return inflight.err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
// Мы первые для этого namespace.
|
||||
inflight := &NSInflightEnsure{done: make(chan struct{})}
|
||||
m.ensuredNSInFlight[ns] = inflight
|
||||
m.ensuredNSMu.Unlock()
|
||||
|
||||
// Берём слот семафора — ограничиваем параллелизм.
|
||||
select {
|
||||
case m.nsSemaphore <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
m.ensuredNSMu.Lock()
|
||||
delete(m.ensuredNSInFlight, ns)
|
||||
m.ensuredNSMu.Unlock()
|
||||
inflight.err = ctx.Err()
|
||||
close(inflight.done)
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
ensureCtx, ensureCancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
inflight.err = m.ensureUserNamespace(ensureCtx, ns)
|
||||
ensureCancel()
|
||||
<-m.nsSemaphore // освобождаем слот
|
||||
|
||||
m.ensuredNSMu.Lock()
|
||||
delete(m.ensuredNSInFlight, ns)
|
||||
if inflight.err == nil {
|
||||
m.ensuredNS[ns] = struct{}{}
|
||||
}
|
||||
m.ensuredNSMu.Unlock()
|
||||
close(inflight.done)
|
||||
|
||||
return inflight.err
|
||||
}
|
||||
|
||||
// ensureUserNamespace выполняет реальную работу: создаёт namespace + RBAC + quota + netpol.
|
||||
// Все операции идемпотентны (IsAlreadyExists игнорируется).
|
||||
func (m *NSManager) ensureUserNamespace(ctx context.Context, ns string) error {
|
||||
// 1. Создать namespace с меткой managed-by=fission-console.
|
||||
// Метка используется reconciler-ом для фильтрации наших namespace-ов.
|
||||
nsObj := &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Namespace",
|
||||
"metadata": map[string]any{
|
||||
"name": ns,
|
||||
"labels": map[string]any{
|
||||
"managed-by": "fission-console",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := m.dyn.Resource(NamespaceGVR).Create(ctx, nsObj, metav1.CreateOptions{})
|
||||
newlyCreated := err == nil
|
||||
if err != nil && !apierrors.IsAlreadyExists(err) {
|
||||
return fmt.Errorf("create namespace %s: %w", ns, err)
|
||||
}
|
||||
|
||||
// 1a. ServiceAccounts для Fission в user namespace.
|
||||
//
|
||||
// Fission pool pods (fetcher sidecar) запускаются в user namespace и требуют
|
||||
// `serviceAccountName: fission-fetcher` в том же namespace. Executor (с SERVICEACCOUNT_CHECK_ENABLED=false)
|
||||
// не создаёт эти SA автоматически → pool pods падают с "serviceaccount not found".
|
||||
saGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "serviceaccounts"}
|
||||
for _, saName := range []string{"fission-fetcher", "fission-builder"} {
|
||||
saObj := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ServiceAccount",
|
||||
"metadata": map[string]any{
|
||||
"name": saName,
|
||||
"namespace": ns,
|
||||
},
|
||||
}}
|
||||
_, saErr := m.dyn.Resource(saGVR).Namespace(ns).Create(ctx, saObj, metav1.CreateOptions{})
|
||||
if saErr != nil && !apierrors.IsAlreadyExists(saErr) {
|
||||
log.Printf("ensureUserNamespace: create SA %s/%s: %v", ns, saName, saErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 1b. RoleBindings для Fission SA в user namespace.
|
||||
//
|
||||
// Почему cluster-admin, а не admin:
|
||||
// ClusterRole "admin" не включает custom resource группы (fission.io/*).
|
||||
// Fission executor при старте пытается создать Role с правами на fission.io/packages,
|
||||
// и получает "attempting to grant RBAC permissions not currently held" — RBAC escalation
|
||||
// prevention. ClusterRole "cluster-admin" в контексте RoleBinding (не ClusterRoleBinding)
|
||||
// даёт полный доступ ТОЛЬКО внутри конкретного namespace — это безопасно.
|
||||
type rbSubject struct {
|
||||
name string
|
||||
namespace string
|
||||
binding string
|
||||
}
|
||||
fissionSysNS := os.Getenv("FISSION_SYSTEM_NAMESPACE")
|
||||
if fissionSysNS == "" {
|
||||
fissionSysNS = "fission"
|
||||
}
|
||||
fissionSAs := []rbSubject{
|
||||
{name: "fission-executor", binding: "fission-executor-user-ns"},
|
||||
{name: "fission-router", binding: "fission-router-user-ns"},
|
||||
{name: "fission-buildermgr", binding: "fission-buildermgr-user-ns"},
|
||||
{name: "fission-kubewatcher", binding: "fission-kubewatcher-user-ns"},
|
||||
{name: "fission-timer", binding: "fission-timer-user-ns"},
|
||||
{name: "fission-fetcher", binding: "fission-fetcher-system-user-ns"},
|
||||
{name: "fission-builder", binding: "fission-builder-system-user-ns"},
|
||||
// fetcher/builder локальные в user namespace (там запускаются pool pods)
|
||||
{name: "fission-fetcher", namespace: ns, binding: "fission-fetcher-local-user-ns"},
|
||||
{name: "fission-builder", namespace: ns, binding: "fission-builder-local-user-ns"},
|
||||
}
|
||||
rbGVR := schema.GroupVersionResource{Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "rolebindings"}
|
||||
for _, sa := range fissionSAs {
|
||||
subjectNS := sa.namespace
|
||||
if subjectNS == "" {
|
||||
subjectNS = fissionSysNS
|
||||
}
|
||||
rbObj := &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"apiVersion": "rbac.authorization.k8s.io/v1",
|
||||
"kind": "RoleBinding",
|
||||
"metadata": map[string]any{
|
||||
"name": sa.binding,
|
||||
"namespace": ns,
|
||||
},
|
||||
"roleRef": map[string]any{
|
||||
"apiGroup": "rbac.authorization.k8s.io",
|
||||
"kind": "ClusterRole",
|
||||
"name": "cluster-admin",
|
||||
},
|
||||
"subjects": []any{
|
||||
map[string]any{
|
||||
"kind": "ServiceAccount",
|
||||
"name": sa.name,
|
||||
"namespace": subjectNS,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, rbErr := m.dyn.Resource(rbGVR).Namespace(ns).Create(ctx, rbObj, metav1.CreateOptions{})
|
||||
if rbErr != nil && !apierrors.IsAlreadyExists(rbErr) {
|
||||
log.Printf("ensureUserNamespace: create rolebinding %s/%s@%s: %v", ns, sa.name, subjectNS, rbErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 1c. ResourceQuota — ограничиваем ресурсы одного пользователя.
|
||||
// Без этого одна функция может исчерпать CPU/RAM всего кластера.
|
||||
// Параметры задаются через env vars для гибкой настройки без пересборки.
|
||||
quotaGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "resourcequotas"}
|
||||
quotaObj := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ResourceQuota",
|
||||
"metadata": map[string]any{"name": "user-quota", "namespace": ns},
|
||||
"spec": map[string]any{
|
||||
"hard": map[string]any{
|
||||
"requests.cpu": envDefault("QUOTA_REQ_CPU", "1"),
|
||||
"requests.memory": envDefault("QUOTA_REQ_MEM", "1Gi"),
|
||||
"limits.cpu": envDefault("QUOTA_LIM_CPU", "4"),
|
||||
"limits.memory": envDefault("QUOTA_LIM_MEM", "4Gi"),
|
||||
"pods": envDefault("QUOTA_PODS", "30"),
|
||||
"count/functions.fission.io": envDefault("QUOTA_FUNCTIONS", "20"),
|
||||
"count/packages.fission.io": envDefault("QUOTA_PACKAGES", "40"),
|
||||
"count/httptriggers.fission.io": envDefault("QUOTA_HTTPTRIGGERS", "20"),
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, quotaErr := m.dyn.Resource(quotaGVR).Namespace(ns).Create(ctx, quotaObj, metav1.CreateOptions{})
|
||||
if quotaErr != nil && !apierrors.IsAlreadyExists(quotaErr) {
|
||||
log.Printf("ensureUserNamespace: create ResourceQuota %s: %v", ns, quotaErr)
|
||||
}
|
||||
|
||||
// 1d. LimitRange — дефолтные лимиты на контейнер.
|
||||
// Поды без явных limits — unbounded; LimitRange автоматически добавляет defaults.
|
||||
limitRangeGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "limitranges"}
|
||||
limitRangeObj := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "v1",
|
||||
"kind": "LimitRange",
|
||||
"metadata": map[string]any{"name": "user-limits", "namespace": ns},
|
||||
"spec": map[string]any{
|
||||
"limits": []any{
|
||||
map[string]any{
|
||||
"type": "Container",
|
||||
"default": map[string]any{"cpu": envDefault("LIMIT_DEFAULT_CPU", "500m"), "memory": envDefault("LIMIT_DEFAULT_MEM", "256Mi")},
|
||||
"defaultRequest": map[string]any{
|
||||
"cpu": envDefault("LIMIT_REQ_CPU", "50m"), "memory": envDefault("LIMIT_REQ_MEM", "64Mi"),
|
||||
},
|
||||
"max": map[string]any{"cpu": envDefault("LIMIT_MAX_CPU", "2"), "memory": envDefault("LIMIT_MAX_MEM", "1Gi")},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, lrErr := m.dyn.Resource(limitRangeGVR).Namespace(ns).Create(ctx, limitRangeObj, metav1.CreateOptions{})
|
||||
if lrErr != nil && !apierrors.IsAlreadyExists(lrErr) {
|
||||
log.Printf("ensureUserNamespace: create LimitRange %s: %v", ns, lrErr)
|
||||
}
|
||||
|
||||
// 1e. NetworkPolicy — запрещаем входящий трафик из других user namespace.
|
||||
// Разрешаем: внутри namespace, из fission core namespace, из kube-system.
|
||||
// Запрещаем: трафик от подов других user namespace (межтенантная изоляция).
|
||||
fissionSysNSForNetpol := os.Getenv("FISSION_SYSTEM_NAMESPACE")
|
||||
if fissionSysNSForNetpol == "" {
|
||||
fissionSysNSForNetpol = "fission"
|
||||
}
|
||||
netpolGVR := schema.GroupVersionResource{Group: "networking.k8s.io", Version: "v1", Resource: "networkpolicies"}
|
||||
netpolObj := &unstructured.Unstructured{Object: map[string]any{
|
||||
"apiVersion": "networking.k8s.io/v1",
|
||||
"kind": "NetworkPolicy",
|
||||
"metadata": map[string]any{"name": "deny-cross-tenant", "namespace": ns},
|
||||
"spec": map[string]any{
|
||||
"podSelector": map[string]any{}, // применяется ко всем подам namespace
|
||||
"policyTypes": []any{"Ingress"},
|
||||
"ingress": []any{
|
||||
// Разрешаем трафик внутри namespace
|
||||
map[string]any{"from": []any{map[string]any{"podSelector": map[string]any{}}}},
|
||||
// Разрешаем трафик из fission core namespace (router → function pod)
|
||||
map[string]any{"from": []any{map[string]any{"namespaceSelector": map[string]any{
|
||||
"matchLabels": map[string]any{"kubernetes.io/metadata.name": fissionSysNSForNetpol},
|
||||
}}}},
|
||||
// Разрешаем трафик из kube-system (kubelet health checks, DNS)
|
||||
map[string]any{"from": []any{map[string]any{"namespaceSelector": map[string]any{
|
||||
"matchLabels": map[string]any{"kubernetes.io/metadata.name": "kube-system"},
|
||||
}}}},
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, npErr := m.dyn.Resource(netpolGVR).Namespace(ns).Create(ctx, netpolObj, metav1.CreateOptions{})
|
||||
if npErr != nil && !apierrors.IsAlreadyExists(npErr) {
|
||||
log.Printf("ensureUserNamespace: create NetworkPolicy %s: %v", ns, npErr)
|
||||
}
|
||||
|
||||
// 1f. Сигналим NS reconciler что появился новый namespace.
|
||||
// Reconciler сам синхронизирует FISSION_RESOURCE_NAMESPACES без race condition и rolling restarts.
|
||||
if newlyCreated {
|
||||
select {
|
||||
case m.NSReconcileCh <- struct{}{}:
|
||||
default: // уже есть сигнал в буфере — не блокируем
|
||||
}
|
||||
}
|
||||
|
||||
// Environments создаются лениво (lazy) в момент создания первой функции на языке.
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartNSReconciler запускает фоновый reconciler FISSION_RESOURCE_NAMESPACES.
|
||||
//
|
||||
// Проблема при scale:
|
||||
// - Прямой патч на горячем пути → rolling restart всех Fission deployments при каждом новом юзере
|
||||
// - Без мьютекса: 100 юзеров одновременно → read-modify-write race → namespace-ы теряются
|
||||
// - Ручное kubectl delete ns → namespace остаётся в переменной вечно
|
||||
//
|
||||
// Решение: единственный goroutine с debounce-каналом.
|
||||
// - Запускается по таймеру или немедленно через NSReconcileCh
|
||||
// - 1000 юзеров одновременно → 1 патч вместо 5000
|
||||
// - Нет race condition (один goroutine, один writer)
|
||||
func (m *NSManager) StartNSReconciler(interval time.Duration) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
log.Printf("nsReconciler: started, interval=%v", interval)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
m.reconcileNSList()
|
||||
case <-m.NSReconcileCh:
|
||||
m.reconcileNSList()
|
||||
// Дренируем канал чтобы не запускаться дважды подряд после сигнала
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case <-m.NSReconcileCh:
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// reconcileNSList синхронизирует FISSION_RESOURCE_NAMESPACES с реально существующими namespace-ами.
|
||||
//
|
||||
// Алгоритм:
|
||||
// 1. Читает все namespace-ы с меткой managed-by=fission-console из k8s (источник истины)
|
||||
// 2. Читает текущий FISSION_RESOURCE_NAMESPACES из router deployment
|
||||
// 3. Вычисляет desired = "default" + активные namespace-ы
|
||||
// 4. Если desired == current → ничего не делает (нет rolling restart!)
|
||||
// 5. Если разница → один патч всех Fission deployments
|
||||
func (m *NSManager) reconcileNSList() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fissionNS := os.Getenv("FISSION_SYSTEM_NAMESPACE")
|
||||
if fissionNS == "" {
|
||||
fissionNS = "fission"
|
||||
}
|
||||
|
||||
// Шаг 1: реальные namespace-ы с нашей меткой
|
||||
nsList, err := m.dyn.Resource(NamespaceGVR).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: "managed-by=fission-console",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("nsReconciler: list namespaces: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Шаг 2: фильтруем — берём только Active. Terminating убираем (они уже умирают).
|
||||
desired := map[string]struct{}{"default": {}}
|
||||
for _, ns := range nsList.Items {
|
||||
phase, _, _ := unstructured.NestedString(ns.Object, "status", "phase")
|
||||
if phase == "Active" {
|
||||
desired[ns.GetName()] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Шаг 3: текущее значение из router deployment
|
||||
routerDep, err := m.dyn.Resource(DeploymentGVR).Namespace(fissionNS).Get(ctx, "router", metav1.GetOptions{})
|
||||
if err != nil {
|
||||
log.Printf("nsReconciler: get router: %v", err)
|
||||
return
|
||||
}
|
||||
currentVal := extractFissionNSEnv(routerDep)
|
||||
|
||||
// Шаг 4: сравниваем current с desired
|
||||
currentSet := map[string]struct{}{}
|
||||
for _, p := range strings.Split(currentVal, ",") {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
currentSet[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
same := len(currentSet) == len(desired)
|
||||
if same {
|
||||
for k := range desired {
|
||||
if _, ok := currentSet[k]; !ok {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if same {
|
||||
return // ничего менять не нужно — нет патча, нет rolling restart
|
||||
}
|
||||
|
||||
// Шаг 5: патчим все Fission deployments
|
||||
parts := make([]string, 0, len(desired))
|
||||
for ns := range desired {
|
||||
parts = append(parts, ns)
|
||||
}
|
||||
sort.Strings(parts)
|
||||
newVal := strings.Join(parts, ",")
|
||||
|
||||
fissionDeployments := []string{"router", "executor", "buildermgr", "kubewatcher", "timer"}
|
||||
patch := map[string]any{
|
||||
"spec": map[string]any{
|
||||
"template": map[string]any{
|
||||
"spec": map[string]any{
|
||||
"containers": []any{
|
||||
map[string]any{
|
||||
"name": "",
|
||||
"env": []any{
|
||||
map[string]any{
|
||||
"name": "FISSION_RESOURCE_NAMESPACES",
|
||||
"value": newVal,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, dep := range fissionDeployments {
|
||||
patch["spec"].(map[string]any)["template"].(map[string]any)["spec"].(map[string]any)["containers"].([]any)[0].(map[string]any)["name"] = dep
|
||||
patchBytes, _ := json.Marshal(patch)
|
||||
if _, pErr := m.dyn.Resource(DeploymentGVR).Namespace(fissionNS).Patch(
|
||||
ctx, dep, types.StrategicMergePatchType, patchBytes, metav1.PatchOptions{}); pErr != nil {
|
||||
log.Printf("nsReconciler: patch %s: %v", dep, pErr)
|
||||
}
|
||||
}
|
||||
log.Printf("nsReconciler: synced FISSION_RESOURCE_NAMESPACES: %s → %s", currentVal, newVal)
|
||||
}
|
||||
|
||||
// extractFissionNSEnv читает FISSION_RESOURCE_NAMESPACES из первого контейнера deployment.
|
||||
func extractFissionNSEnv(dep *unstructured.Unstructured) string {
|
||||
containers, _, _ := unstructured.NestedSlice(dep.Object, "spec", "template", "spec", "containers")
|
||||
for _, c := range containers {
|
||||
cont, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
envs, _, _ := unstructured.NestedSlice(cont, "env")
|
||||
for _, e := range envs {
|
||||
env, ok := e.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if env["name"] == "FISSION_RESOURCE_NAMESPACES" {
|
||||
if v, ok := env["value"].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
break // смотрим только первый контейнер
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
// StartExpiryReaper запускает фоновый goroutine для удаления функций с истёкшим TTL.
|
||||
func (m *NSManager) StartExpiryReaper(interval time.Duration) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
log.Printf("expiryReaper: started, interval=%v", interval)
|
||||
for range ticker.C {
|
||||
m.runExpiryReap()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// runExpiryReap обходит все управляемые namespace-ы и удаляет протухшие функции.
|
||||
func (m *NSManager) runExpiryReap() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
nsList, err := m.dyn.Resource(NamespaceGVR).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: "managed-by=fission-console",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("expiryReaper: list namespaces: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, ns := range nsList.Items {
|
||||
m.reapExpiredFunctionsInNS(ctx, ns.GetName(), now)
|
||||
}
|
||||
}
|
||||
|
||||
// reapExpiredFunctionsInNS удаляет протухшие функции в конкретном namespace.
|
||||
// Для каждой удалённой функции вызывает CleanupEnvironmentIfUnused.
|
||||
// Также удаляет orphan packages — пакеты без соответствующей функции.
|
||||
func (m *NSManager) reapExpiredFunctionsInNS(ctx context.Context, ns string, now time.Time) {
|
||||
functions, err := m.dyn.Resource(FunctionGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
log.Printf("expiryReaper: list functions in %s: %v", ns, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Строим множество активных функций для поиска orphan packages
|
||||
activeFunctions := make(map[string]struct{}, len(functions.Items))
|
||||
for _, fn := range functions.Items {
|
||||
activeFunctions[fn.GetName()] = struct{}{}
|
||||
}
|
||||
|
||||
for _, fn := range functions.Items {
|
||||
expiresAtStr, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/expires-at")
|
||||
if expiresAtStr == "" {
|
||||
continue // нет TTL — функция живёт вечно
|
||||
}
|
||||
expiresAt, parseErr := time.Parse(time.RFC3339, expiresAtStr)
|
||||
if parseErr != nil {
|
||||
log.Printf("expiryReaper: parse expires-at for %s/%s: %v", ns, fn.GetName(), parseErr)
|
||||
continue
|
||||
}
|
||||
if now.Before(expiresAt) {
|
||||
continue // ещё не протухла
|
||||
}
|
||||
|
||||
fnName := fn.GetName()
|
||||
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
||||
pkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
||||
log.Printf("expiryReaper: deleting expired function %s/%s (expired %s ago)", ns, fnName, now.Sub(expiresAt).Round(time.Second))
|
||||
|
||||
// Удаляем HTTPTrigger-ы ссылающиеся на эту функцию
|
||||
triggers, tErr := m.dyn.Resource(HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||
if tErr == nil {
|
||||
for _, trig := range triggers.Items {
|
||||
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
|
||||
if refName == fnName {
|
||||
_ = m.dyn.Resource(HTTPTrigGVR).Namespace(ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = m.dyn.Resource(FunctionGVR).Namespace(ns).Delete(ctx, fnName, metav1.DeleteOptions{})
|
||||
if pkgName != "" {
|
||||
_ = m.dyn.Resource(PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
|
||||
}
|
||||
if envName != "" {
|
||||
CleanupEnvironmentIfUnused(ctx, m.dyn, ns, envName)
|
||||
}
|
||||
}
|
||||
|
||||
// Сигналим reconciler — он проверит все namespace-ы и почистит список
|
||||
select {
|
||||
case m.NSReconcileCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
// Orphan packages — пакеты без соответствующей функции
|
||||
// (остаются если процесс упал в середине удаления)
|
||||
packages, pkgListErr := m.dyn.Resource(PackageGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||
if pkgListErr == nil {
|
||||
for _, pkg := range packages.Items {
|
||||
pkgName := pkg.GetName()
|
||||
// Конвенция именования: {fn-name}-pkg
|
||||
if !strings.HasSuffix(pkgName, "-pkg") {
|
||||
continue
|
||||
}
|
||||
fnName := strings.TrimSuffix(pkgName, "-pkg")
|
||||
if _, exists := activeFunctions[fnName]; !exists {
|
||||
log.Printf("expiryReaper: deleting orphan package %s/%s (no matching function)", ns, pkgName)
|
||||
_ = m.dyn.Resource(PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// envDefault читает env переменную или возвращает fallback.
|
||||
func envDefault(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Package model содержит общие типы данных (request/response структуры,
|
||||
// конфигурации языков), которые используются в нескольких пакетах.
|
||||
// Вынесено отдельно чтобы избежать циклических зависимостей между пакетами.
|
||||
package model
|
||||
|
||||
// CreateFunctionRequest — тело POST /console/api/functions.
|
||||
// TTL пустой → функция живёт вечно; "1d", "24h" — протухнет через указанное время.
|
||||
type CreateFunctionRequest struct {
|
||||
Name string `json:"name"`
|
||||
Language string `json:"language"`
|
||||
Environment string `json:"environment"`
|
||||
Code string `json:"code"`
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
Route string `json:"route"`
|
||||
Methods []string `json:"methods"`
|
||||
TTL string `json:"ttl"` // e.g. "24h", "7d" — пустое = функция не протухает
|
||||
}
|
||||
|
||||
// UpdateCodeRequest — тело PUT /console/api/functions/:name/code.
|
||||
type UpdateCodeRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// LangEnvDef описывает Docker-образы для конкретного языка.
|
||||
// BuilderImage заполнен только для языков которым нужна компиляция (Go).
|
||||
// Version: версия среды Fission (по умолчанию 3; perl использует 1 — нет поддержки async entrypoint).
|
||||
type LangEnvDef struct {
|
||||
Image string
|
||||
BuilderImage string
|
||||
Version int // 0 → по умолчанию 3
|
||||
}
|
||||
|
||||
// LangEnvMap сопоставляет идентификатор языка (string) с описанием среды выполнения.
|
||||
// Ключ используется в createFunctionRequest.Language и как суффикс имени Environment.
|
||||
//
|
||||
// Почему perl Version=1:
|
||||
// Fission Environment v3 требует поддержки async entrypoint (V2 protocol).
|
||||
// ghcr.io/fission/perl-env поддерживает только V1 protocol → version=1.
|
||||
var LangEnvMap = map[string]LangEnvDef{
|
||||
"python": {Image: "ghcr.io/fission/python-env"},
|
||||
"nodejs": {Image: "ghcr.io/fission/node-env"},
|
||||
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"},
|
||||
"php": {Image: "ghcr.io/fission/php-env"},
|
||||
"ruby": {Image: "ghcr.io/fission/ruby-env"},
|
||||
"perl": {Image: "ghcr.io/fission/perl-env", Version: 1},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package runtime отвечает за подготовку исходного кода функции
|
||||
// к загрузке в Fission: упаковка в zip, обёртка ESM для Node.js и т.д.
|
||||
// Каждый язык — отдельный файл; общая логика zip — в helpers.go.
|
||||
package runtime
|
||||
@@ -0,0 +1,30 @@
|
||||
package runtime
|
||||
|
||||
// DefaultEntrypoint возвращает точку входа по умолчанию для языка lang.
|
||||
//
|
||||
// Формат entrypoint зависит от среды выполнения Fission:
|
||||
// - python: "module.function" → Fission импортирует модуль и вызывает функцию
|
||||
// - nodejs: "main" → имя экспортированной default-функции в main.js
|
||||
// - php: "main.php::handler" → имя файла + "::" + имя функции
|
||||
// - ruby: "handler" → имя метода в загруженном файле
|
||||
// - perl: "handler" → имя функции в загруженном файле
|
||||
// - go: "Handler" → экспортированная Go-функция (с большой буквы)
|
||||
func DefaultEntrypoint(lang string) string {
|
||||
switch lang {
|
||||
case "nodejs":
|
||||
return "main"
|
||||
case "php":
|
||||
// Fission php-env: filename::functionName
|
||||
return "main.php::handler"
|
||||
case "ruby":
|
||||
return "handler"
|
||||
case "perl":
|
||||
return "handler"
|
||||
case "go":
|
||||
// Go: экспортированная функция (заглавная) — go/plugin требует экспорт
|
||||
return "Handler"
|
||||
default:
|
||||
// python и неизвестные языки: "main.main" = модуль main, функция main
|
||||
return "main.main"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
)
|
||||
|
||||
// BuildGoSourceZip упаковывает исходный Go-код в zip для Fission source package.
|
||||
//
|
||||
// Fission Go-среда работает в два этапа:
|
||||
// 1. Package создаётся с source.literal = zip(handler.go + go.mod)
|
||||
// 2. go-builder job компилирует его в плагин (.so) — это занимает ~40s первый раз
|
||||
//
|
||||
// Важно: source package, а НЕ deployment package — Fission сам компилирует.
|
||||
// Entrypoint по умолчанию "Handler" (экспортированная функция в handler.go).
|
||||
func BuildGoSourceZip(code string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
// handler.go — пользовательский код
|
||||
fw, err := zw.Create("handler.go")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := fw.Write([]byte(code)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// go.mod — минимальный модуль для сборки; go-builder добавит нужные зависимости
|
||||
goMod := "module github.com/user/fn\n\ngo 1.23\n"
|
||||
fw2, err := zw.Create("go.mod")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := fw2.Write([]byte(goMod)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
)
|
||||
|
||||
// buildZip создаёт zip-архив с одним файлом fileName и содержимым content.
|
||||
// Вспомогательная функция: используется в buildScriptZip (PHP/Ruby/Perl),
|
||||
// а также как основа для buildGoSourceZip и buildJSDeployZip.
|
||||
func buildZip(fileName string, content []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
fw, err := zw.Create(fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := fw.Write(content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// BuildJSDeployZip упаковывает Node.js код в zip для Fission deployment package.
|
||||
//
|
||||
// Почему zip, а не просто literal-код:
|
||||
// Fission node-env v3 поддерживает ESM модули. Чтобы Node.js трактовал файл
|
||||
// как ESM, нужен package.json с {"type":"module"} рядом с main.js.
|
||||
// Без него `import` синтаксис вызовет "require is not defined" или SyntaxError.
|
||||
//
|
||||
// Почему new Function:
|
||||
// Пользовательский код пишет CJS-стиль (module.exports = ...) но мы хотим ESM wrapper.
|
||||
// new Function() изолирует module/exports от глобального ESM контекста и позволяет
|
||||
// исполнять CJS-код без изменений.
|
||||
//
|
||||
// Результат: zip с двумя файлами:
|
||||
// - package.json: {"type":"module"}
|
||||
// - main.js: ESM wrapper + инлайн пользовательский код через new Function
|
||||
func BuildJSDeployZip(code string) ([]byte, error) {
|
||||
// Сериализуем пользовательский код в JSON строку чтобы безопасно инлайнить
|
||||
// в JavaScript-литерал — экранирует кавычки, переводы строк, спецсимволы.
|
||||
codeJSON, err := json.Marshal(code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal user code: %w", err)
|
||||
}
|
||||
|
||||
// main.js — ESM wrapper:
|
||||
// 1. new Function создаёт функцию в пустом модульном контексте (нет import/export)
|
||||
// 2. Передаём ей module и exports как параметры → пользовательский CJS код работает
|
||||
// 3. Экспортируем default async function для Fission node-env entrypoint
|
||||
wrapper := fmt.Sprintf(`const __mod = { exports: {} };
|
||||
(new Function('module', 'exports', %s))(__mod, __mod.exports);
|
||||
const _fn = __mod.exports;
|
||||
|
||||
export default async function(ctx) {
|
||||
const fn = typeof _fn === 'function' ? _fn : (_fn.default || _fn.handler || _fn.main);
|
||||
if (!fn) throw new Error('no exported function found in user code');
|
||||
const result = await fn(ctx);
|
||||
if (!result) return { status: 200, body: '' };
|
||||
if (typeof result.status !== 'undefined') return result;
|
||||
return { status: 200, ...result };
|
||||
}
|
||||
`, string(codeJSON))
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
// package.json: включает ESM режим для всего архива
|
||||
pkgfw, err := zw.Create("package.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := pkgfw.Write([]byte(`{"type":"module"}`)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fw, err := zw.Create("main.js")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := fw.Write([]byte(wrapper)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package runtime
|
||||
|
||||
// BuildScriptZip создаёт zip-архив с одним файлом fileName и содержимым code.
|
||||
// Используется для PHP, Ruby, Perl — языков где среда Fission ожидает
|
||||
// именованный файл (handler.rb, handler.pl, main.php и т.д.) внутри архива.
|
||||
//
|
||||
// Почему zip, а не просто literal:
|
||||
// Fission poolmgr при специализации пода распаковывает deployment archive,
|
||||
// находит нужный файл по имени и загружает его в среду выполнения.
|
||||
// Если передать просто байты кода в deployment.literal — среда не знает расширение.
|
||||
func BuildScriptZip(code, fileName string) ([]byte, error) {
|
||||
return buildZip(fileName, []byte(code))
|
||||
}
|
||||
Reference in New Issue
Block a user