Merge branch 'feat/ui-archive-upload' of https://gitea.services.ngcloud.ru/Nail/fission into feat/ui-archive-upload

This commit is contained in:
Naeel
2026-05-01 12:39:57 +03:00
13 changed files with 1091 additions and 516 deletions
+9
View File
@@ -0,0 +1,9 @@
package api
import (
"net/http"
)
func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
// ...existing code...
}
+19
View File
@@ -0,0 +1,19 @@
package api
import "net/http"
func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
// ...existing code...
}
func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name string) {
// ...existing code...
}
func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request, name string) {
// ...existing code...
}
func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) {
// ...existing code...
}
+21 -511
View File
@@ -3,7 +3,6 @@ package api
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -37,189 +36,10 @@ const maxCodeSize = 1 << 20
// defaultFunctionInvokeTimeout совпадает с дефолтом Fission для spec.functionTimeout.
const defaultFunctionInvokeTimeout = 60 * time.Second
func buildDeployArchive(lang, code string) ([]byte, error) {
switch lang {
case "nodejs":
return runtime.BuildJSDeployZip(code)
case "php":
return runtime.BuildScriptZip(code, "main.php")
case "ruby":
return runtime.BuildScriptZip(code, "handler.rb")
default:
return []byte(code), nil
}
}
func (s *Server) resolveInvokeTimeout(fn *unstructured.Unstructured) time.Duration {
if fn != nil {
seconds, found, err := unstructured.NestedInt64(fn.Object, "spec", "functionTimeout")
if err == nil && found && seconds > 0 {
return time.Duration(seconds) * time.Second
}
}
if s.invokeTimeout > 0 {
return s.invokeTimeout
}
return defaultFunctionInvokeTimeout
}
func normalizeFunctionTimeout(seconds int64) int64 {
if seconds <= 0 {
return int64(defaultFunctionInvokeTimeout / time.Second)
}
return seconds
}
func normalizeRoute(route string) string {
route = strings.TrimSpace(route)
if route == "" || route == "/" {
return "/"
}
if !strings.HasPrefix(route, "/") {
return "/" + route
}
return route
}
func routeAllowsMethod(methods []string, method string) bool {
if len(methods) == 0 {
return true
}
for _, candidate := range methods {
if strings.EqualFold(strings.TrimSpace(candidate), method) {
return true
}
}
return false
}
func appendUniqueMethods(dst []string, src []string) []string {
for _, method := range src {
method = strings.ToUpper(strings.TrimSpace(method))
if method == "" {
continue
}
seen := false
for _, existing := range dst {
if existing == method {
seen = true
break
}
}
if !seen {
dst = append(dst, method)
}
}
return dst
}
func shouldForwardRequestBody(method string) bool {
switch method {
case http.MethodGet, http.MethodHead:
return false
default:
return true
}
}
func copyProxyRequestHeaders(dst, src http.Header) {
for key, values := range src {
switch http.CanonicalHeaderKey(key) {
case "Authorization", "X-Auth-Token", "X-Auth-Env", "Host", "Content-Length":
continue
}
for _, value := range values {
dst.Add(key, value)
}
}
}
func copyProxyResponseHeaders(dst, src http.Header) {
for key, values := range src {
for _, value := range values {
dst.Add(key, value)
}
}
}
func doRequestWithContextTimeout(client *http.Client, req *http.Request) (*http.Response, error) {
if client == nil {
return http.DefaultClient.Do(req)
}
invokeClient := *client
// Для invoke реальный лимит должен определяться context timeout функции,
// а не общим HTTP timeout console.
invokeClient.Timeout = 0
return invokeClient.Do(req)
}
// 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)
@@ -301,140 +121,19 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
return
}
pkgName := req.Name + "-pkg"
triggerName := req.Name + "-route"
methodValues := make([]any, 0, len(req.Methods))
for _, method := range req.Methods {
methodValues = append(methodValues, method)
}
// Строим Package spec в зависимости от языка:
// - Go: source package → builder job компилирует в .so плагин
// - Node.js: deployment zip с ESM wrapper (package.json + main.js)
// - Остальные: deployment literal с кодом напрямую
var pkgSpec map[string]any
if req.Language == "go" {
srcZip, err := runtime.BuildGoSourceZip(req.Code)
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("build go source archive: %v", err))
return
}
literal := base64.StdEncoding.EncodeToString(srcZip)
pkgSpec = map[string]any{
"source": map[string]any{
"type": "literal",
"literal": literal,
},
"deployment": map[string]any{},
"environment": map[string]any{"name": req.Environment, "namespace": ns},
"buildcommand": "build",
}
} else {
deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code)
if archiveErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr))
return
}
pkgSpec = map[string]any{
"deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)},
"environment": map[string]any{"name": req.Environment, "namespace": ns},
"source": map[string]any{},
}
}
pkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": pkgName, "namespace": ns},
"spec": pkgSpec,
}}
// Парсим TTL ДО создания K8s ресурсов — невалидный TTL не оставляет мусор
fnAnnotations := map[string]any{
"fission-console/language": req.Language,
}
now := time.Now().UTC()
fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
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 {
result, err := fission.CreateFunction(ctx, s.dyn, ns, req)
if err != nil {
status := http.StatusBadGateway
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name))
return
status = http.StatusConflict
} else if apierrors.IsInvalid(err) {
status = http.StatusBadRequest
}
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))
writeJSONError(w, status, err.Error())
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},
"functionTimeout": req.Timeout,
"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"],
})
writeAnyJSON(w, http.StatusCreated, result)
}
// handleGetFunction возвращает детали функции: код, environment, route, methods.
@@ -466,7 +165,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
if packageName != "" {
pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, packageName, metav1.GetOptions{})
if pkgErr == nil {
code = extractPackageSourceCode(ctx, s, pkg)
code = extractPackageSourceCode(ctx, s.http, pkg)
}
}
@@ -502,10 +201,6 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
})
}
// handleUpdateFunctionCode обновляет код уже существующей функции.
// Создаёт НОВЫЙ Package (вместо обновления старого) чтобы executor сбросил кэш:
// executor кэширует function service по functionUid и не видит изменений в том же Package.
// Новое имя пакета гарантирует cache miss в executor.
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 {
@@ -517,109 +212,25 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
writeJSONError(w, http.StatusBadRequest, "code is required")
return
}
if req.Timeout <= 0 {
req.Timeout = int64(defaultFunctionInvokeTimeout / time.Second)
}
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{})
result, err := fission.UpdateFunctionCode(ctx, s.dyn, ns, name, req)
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err))
writeJSONError(w, status, err.Error())
return
}
oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
// Определяем язык из аннотации — нужен для правильной упаковки
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
deployBytes, archiveErr := buildDeployArchive(lang, req.Code)
if archiveErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr))
return
}
// Создаём новый Package с уникальным именем.
// Это единственный способ сбросить кэш executor: он кэширует по functionUid и
// не замечает изменений в существующем Package.
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
createdAt := func() time.Time {
ann := fn.GetAnnotations()
if ann != nil {
if v := strings.TrimSpace(ann[functionCreatedAtAnnotation]); v != "" {
if ts, err := parseRFC3339(v); err == nil {
return ts.UTC()
}
}
}
if ts := fn.GetCreationTimestamp(); !ts.IsZero() {
return ts.UTC()
}
return time.Time{}
}()
now := time.Now().UTC()
newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36)
newPkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
"spec": map[string]any{
"deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)},
"environment": map[string]any{"name": envName, "namespace": ns},
"source": map[string]any{},
},
}}
createdPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create new package: %v", err))
return
}
// Обновляем Function на новый Package
if err := unstructured.SetNestedField(fn.Object, normalizeFunctionTimeout(req.Timeout), "spec", "functionTimeout"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function timeout: %v", err))
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
return
}
ensureFunctionTimestamps(fn, now)
if createdAt.IsZero() {
createdAt = now
}
fnAnnotations := fn.GetAnnotations()
if fnAnnotations == nil {
fnAnnotations = map[string]string{}
}
fnAnnotations[functionCreatedAtAnnotation] = createdAt.UTC().Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
fn.SetAnnotations(fnAnnotations)
if err := unstructured.SetNestedField(fn.Object, map[string]any{
"name": newPkgName,
"namespace": ns,
"resourceversion": createdPkg.GetResourceVersion(),
}, "spec", "package", "packageref"); err != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function packageref: %v", err))
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
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: %v", name, err))
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
return
}
// Удаляем старый Package (best effort)
if oldPkgName != "" && oldPkgName != newPkgName {
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, oldPkgName, metav1.DeleteOptions{})
}
writeAnyJSON(w, http.StatusOK, map[string]any{
"updated": true,
"package": newPkgName,
})
writeAnyJSON(w, http.StatusOK, result)
}
// handleInvokeFunction вызывает функцию через Fission router.
@@ -951,109 +562,26 @@ func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, resp.Body)
}
// handleDeleteFunction удаляет функцию и связанные объекты: HTTPTrigger, TimeTrigger, 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 err := fission.DeleteFunction(ctx, s.dyn, ns, name); err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", name))
return
status = http.StatusNotFound
}
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{})
}
}
}
// Удаляем связанные TimeTrigger-ы
if triggers, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{}); err == nil {
for _, trig := range triggers.Items {
refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name")
if refName == name {
_ = s.dyn.Resource(fission.TimeTrigGVR).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))
writeJSONError(w, status, err.Error())
return
}
if pkgName != "" {
if err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete package %q: %v", pkgName, err))
return
}
}
// Убираем environment pool pods если язык больше не используется (best-effort)
if envName != "" {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cleanupCancel()
fission.CleanupEnvironmentIfUnused(cleanupCtx, s.dyn, ns, envName)
}
// (reconciler NS удалён — за FISSION_RESOURCE_NAMESPACES теперь отвечает Layer 1 NSWatcher)
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name, "package": pkgName})
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
}
// 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"
}
ns, err := s.resolveNamespaceForToken(body.Token, env, s.testMode)
if err != nil {
writeJSONError(w, http.StatusUnauthorized, "invalid token")
return
}
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).
@@ -1078,22 +606,4 @@ func parseTTL(ttl string) (time.Time, error) {
// 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
}
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"net/http"
"time"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, name string) {
// ...existing code...
}
func (s *Server) handleFissionFunctionGateway(w http.ResponseWriter, r *http.Request) {
// ...existing code...
}
func (s *Server) invokeInternalFunction(w http.ResponseWriter, r *http.Request, namespace, functionName, extraPath string) {
// ...existing code...
}
func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) {
// ...existing code...
}
func buildInternalInvokeURL(routerURL, namespace, functionName string) string {
// ...existing code...
}
func resolveInvokeTimeout(fn *unstructured.Unstructured) time.Duration {
// ...existing code...
}
func shouldForwardRequestBody(method string) bool {
// ...existing code...
}
func copyProxyRequestHeaders(dst, src http.Header) {
// ...existing code...
}
func copyProxyResponseHeaders(dst, src http.Header) {
// ...existing code...
}
func doRequestWithContextTimeout(client *http.Client, req *http.Request) (*http.Response, error) {
// ...existing code...
}
+7 -4
View File
@@ -24,7 +24,7 @@ import (
//
// Декодирование: если данные — валидный UTF-8, возвращаем как есть.
// Если это zip — ищем известные файлы (main.py, handler.rb и т.д.).
func extractPackageSourceCode(ctx context.Context, s *Server, pkg *unstructured.Unstructured) string {
func extractPackageSourceCode(ctx context.Context, httpClient *http.Client, pkg *unstructured.Unstructured) string {
literalPaths := [][]string{
{"spec", "source", "literal"},
{"spec", "deployment", "literal"},
@@ -49,7 +49,7 @@ func extractPackageSourceCode(ctx context.Context, s *Server, pkg *unstructured.
if !found || strings.TrimSpace(urlValue) == "" {
continue
}
archiveBytes, err := fetchPackageArchive(ctx, s, urlValue)
archiveBytes, err := fetchPackageArchive(ctx, httpClient, urlValue)
if err != nil {
continue
}
@@ -62,12 +62,15 @@ func extractPackageSourceCode(ctx context.Context, s *Server, pkg *unstructured.
}
// fetchPackageArchive скачивает архив функции по URL из Fission storage.
func fetchPackageArchive(ctx context.Context, s *Server, archiveURL string) ([]byte, error) {
func fetchPackageArchive(ctx context.Context, httpClient *http.Client, 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 httpClient == nil {
httpClient = http.DefaultClient
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
+25
View File
@@ -0,0 +1,25 @@
package api
import (
"strings"
)
func normalizeFunctionTimeout(seconds int64) int64 {
// ...existing code...
}
func normalizeRoute(route string) string {
// ...existing code...
}
func normalizeMethods(in []string) []string {
// ...existing code...
}
func routeAllowsMethod(methods []string, method string) bool {
// ...existing code...
}
func appendUniqueMethods(dst []string, src []string) []string {
// ...existing code...
}
+258
View File
@@ -0,0 +1,258 @@
package fission
import (
"context"
"encoding/base64"
"fmt"
"strconv"
"time"
"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"
"k8s.io/client-go/dynamic"
)
const (
functionCreatedAtAnnotation = "fission-console/createdAt"
functionUpdatedAtAnnotation = "fission-console/updatedAt"
)
// CreateFunctionResult holds the result of a CreateFunction call.
type CreateFunctionResult struct {
Name string `json:"name"`
Package string `json:"package"`
HTTPTrigger string `json:"httptrigger"`
Route string `json:"route"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// UpdateFunctionResult holds the result of an UpdateFunctionCode call.
type UpdateFunctionResult struct {
Updated bool `json:"updated"`
Package string `json:"package"`
}
// CreateFunction orchestrates the creation of a complete Fission function set: Package, Function, and HTTPTrigger.
func CreateFunction(ctx context.Context, dyn dynamic.Interface, ns string, req model.CreateFunctionRequest) (*CreateFunctionResult, error) {
pkgName := req.Name + "-pkg"
triggerName := req.Name + "-route"
methodValues := make([]any, 0, len(req.Methods))
for _, method := range req.Methods {
methodValues = append(methodValues, method)
}
var pkgSpec map[string]any
if req.Language == "go" {
srcZip, err := runtime.BuildGoSourceZip(req.Code)
if err != nil {
return nil, fmt.Errorf("build go source archive: %w", err)
}
literal := base64.StdEncoding.EncodeToString(srcZip)
pkgSpec = map[string]any{
"source": map[string]any{
"type": "literal",
"literal": literal,
},
"deployment": map[string]any{},
"environment": map[string]any{"name": req.Environment, "namespace": ns},
"buildcommand": "build",
}
} else {
deployBytes, archiveErr := runtime.BuildDeployArchive(req.Language, req.Code)
if archiveErr != nil {
return nil, fmt.Errorf("build %s archive: %w", req.Language, archiveErr)
}
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,
}}
fnAnnotations := map[string]any{
"fission-console/language": req.Language,
}
now := time.Now().UTC()
fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
var expiresAtStr string
if req.TTL != "" {
expiresAt, ttlErr := parseTTL(req.TTL)
if ttlErr != nil {
return nil, fmt.Errorf("invalid ttl %q: %w", req.TTL, ttlErr)
}
expiresAtStr = expiresAt.UTC().Format(time.RFC3339)
fnAnnotations["fission-console/expires-at"] = expiresAtStr
}
if _, err := dyn.Resource(PackageGVR).Namespace(ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil {
return nil, fmt.Errorf("create package: %w", err)
}
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},
"functionTimeout": req.Timeout,
"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 := dyn.Resource(FunctionGVR).Namespace(ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil {
_ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), pkgName, metav1.DeleteOptions{})
return nil, fmt.Errorf("create function: %w", err)
}
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 := dyn.Resource(HTTPTrigGVR).Namespace(ns).Create(ctx, httpTrigger, metav1.CreateOptions{}); err != nil {
_ = dyn.Resource(FunctionGVR).Namespace(ns).Delete(context.Background(), req.Name, metav1.DeleteOptions{})
_ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), pkgName, metav1.DeleteOptions{})
return nil, fmt.Errorf("create httptrigger: %w", err)
}
return &CreateFunctionResult{
Name: req.Name,
Package: pkgName,
HTTPTrigger: triggerName,
Route: req.Route,
ExpiresAt: expiresAtStr,
}, nil
}
// UpdateFunctionCode orchestrates updating the code of an existing function.
func UpdateFunctionCode(ctx context.Context, dyn dynamic.Interface, ns, name string, req model.UpdateCodeRequest) (*UpdateFunctionResult, error) {
fn, err := dyn.Resource(FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("get function %q: %w", name, err)
}
oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
deployBytes, archiveErr := runtime.BuildDeployArchive(lang, req.Code)
if archiveErr != nil {
return nil, fmt.Errorf("build %s archive: %w", lang, archiveErr)
}
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36)
newPkg := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]any{"name": newPkgName, "namespace": ns},
"spec": map[string]any{
"deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)},
"environment": map[string]any{"name": envName, "namespace": ns},
"source": map[string]any{},
},
}}
createdPkg, err := dyn.Resource(PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{})
if err != nil {
return nil, fmt.Errorf("create new package: %w", err)
}
if err := unstructured.SetNestedField(fn.Object, req.Timeout, "spec", "functionTimeout"); err != nil {
_ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), newPkgName, metav1.DeleteOptions{})
return nil, fmt.Errorf("set function timeout: %w", err)
}
annotations := fn.GetAnnotations()
if annotations == nil {
annotations = make(map[string]string)
}
annotations[functionUpdatedAtAnnotation] = time.Now().UTC().Format(time.RFC3339)
fn.SetAnnotations(annotations)
if err := unstructured.SetNestedField(fn.Object, map[string]any{
"name": newPkgName,
"namespace": ns,
"resourceversion": createdPkg.GetResourceVersion(),
}, "spec", "package", "packageref"); err != nil {
_ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), newPkgName, metav1.DeleteOptions{})
return nil, fmt.Errorf("set function packageref: %w", err)
}
if _, err := dyn.Resource(FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
_ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), newPkgName, metav1.DeleteOptions{})
return nil, fmt.Errorf("update function %q: %w", name, err)
}
if oldPkgName != "" && oldPkgName != newPkgName {
_ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), oldPkgName, metav1.DeleteOptions{})
}
return &UpdateFunctionResult{
Updated: true,
Package: newPkgName,
}, nil
}
// DeleteFunction orchestrates the deletion of a function and its related resources.
func DeleteFunction(ctx context.Context, dyn dynamic.Interface, ns, name string) error {
// Best-effort deletion of related resources.
_ = dyn.Resource(HTTPTrigGVR).Namespace(ns).Delete(ctx, name+"-route", metav1.DeleteOptions{})
fn, err := dyn.Resource(FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{})
if err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("get function for deletion: %w", err)
}
if err == nil {
if pkgName, found, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name"); found && pkgName != "" {
_ = dyn.Resource(PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
}
}
if err := dyn.Resource(FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("delete function: %w", err)
}
if fn != nil {
if envName, found, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name"); found && envName != "" {
CleanupEnvironmentIfUnused(context.Background(), dyn, ns, envName)
}
}
return nil
}
func parseTTL(ttl string) (time.Time, error) {
duration, err := time.ParseDuration(ttl)
if err != nil {
return time.Time{}, err
}
return time.Now().UTC().Add(duration), nil
}
+22
View File
@@ -0,0 +1,22 @@
package runtime
// BuildDeployArchive creates a deployment archive for a given language and code.
// For Node.js, it creates a zip with an ESM wrapper.
// For script languages (PHP, Ruby), it creates a zip with the correct entrypoint filename.
// For others, it returns the code as is.
func BuildDeployArchive(lang, code string) ([]byte, error) {
switch lang {
case "nodejs":
return BuildJSDeployZip(code)
case "php":
return BuildScriptZip(code, "main.php")
case "ruby":
return BuildScriptZip(code, "handler.rb")
case "python", "go", "perl": // Go is a special case handled in CreateFunction, but for archive purposes it's just the source.
return []byte(code), nil
default:
// Allow unknown languages but treat them as simple scripts.
// The environment itself will fail later if the language is truly unsupported.
return []byte(code), nil
}
}