343 lines
13 KiB
Go
343 lines
13 KiB
Go
// Package api — создание и обновление функций из zip-архива (multipart/form-data).
|
|
//
|
|
// Этот файл отвечает за два сценария:
|
|
// 1. handleCreateFunctionFromArchive — создание новой функции из загруженного .zip файла.
|
|
// 2. handleUpdateFunctionArchive — обновление существующей функции новым .zip файлом.
|
|
//
|
|
// Архив загружается пользователем через форму с полем "archive".
|
|
// Содержимое архива передаётся в storagesvc → S3 без модификации.
|
|
// В отличие от function_code.go, здесь нет трансформации кода — архив идёт как есть.
|
|
//
|
|
// Связанная аннотация: fission-console/source-type = "archive"
|
|
// позволяет UI определить режим редактирования при открытии функции.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"fission-console/internal/fission"
|
|
"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"
|
|
)
|
|
|
|
// handleCreateFunctionFromArchive создаёт функцию из загруженного zip-архива (multipart/form-data).
|
|
// Поля формы: name, language (или environment), entrypoint, route, methods, timeout, ttl.
|
|
// Файловое поле: archive (.zip).
|
|
func (s *Server) handleCreateFunctionFromArchive(w http.ResponseWriter, r *http.Request, ns string) {
|
|
if err := r.ParseMultipartForm(maxArchiveUploadSize); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("parse multipart form: %v", err))
|
|
return
|
|
}
|
|
|
|
name := strings.TrimSpace(r.FormValue("name"))
|
|
if name == "" || (!validFuncName.MatchString(name) || len(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
|
|
}
|
|
|
|
lang := strings.TrimSpace(r.FormValue("language"))
|
|
envName := strings.TrimSpace(r.FormValue("environment"))
|
|
|
|
f, fhCreate, err := r.FormFile("archive")
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("archive file required: %v", err))
|
|
return
|
|
}
|
|
defer f.Close()
|
|
archiveBytes, err := io.ReadAll(io.LimitReader(f, maxArchiveUploadSize))
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read archive: %v", err))
|
|
return
|
|
}
|
|
archiveFilenameCreate := ""
|
|
if fhCreate != nil {
|
|
archiveFilenameCreate = fhCreate.Filename
|
|
}
|
|
|
|
// Проверяем magic bytes: zip должен начинаться с PK (0x50 0x4B)
|
|
if len(archiveBytes) < 4 || archiveBytes[0] != 0x50 || archiveBytes[1] != 0x4B {
|
|
writeJSONError(w, http.StatusBadRequest, "загруженный файл не является zip-архивом (ожидается .zip)")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
|
|
// Определяем environment: по языку или явно
|
|
if lang != "" {
|
|
envCtx, envCancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
defer envCancel()
|
|
resolved, envErr := fission.EnsureEnvironment(envCtx, s.dyn, ns, lang)
|
|
if envErr != nil {
|
|
if strings.Contains(envErr.Error(), "unsupported language") {
|
|
writeJSONError(w, http.StatusBadRequest, envErr.Error())
|
|
} else {
|
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure environment: %v", envErr))
|
|
}
|
|
return
|
|
}
|
|
envName = resolved
|
|
}
|
|
if envName == "" {
|
|
writeJSONError(w, http.StatusBadRequest, "language or environment is required")
|
|
return
|
|
}
|
|
if _, err := s.dyn.Resource(fission.EnvironmentGVR).Namespace(ns).Get(ctx, envName, metav1.GetOptions{}); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("environment %q not found: %v", envName, err))
|
|
return
|
|
}
|
|
|
|
entrypoint := strings.TrimSpace(r.FormValue("entrypoint"))
|
|
if entrypoint == "" {
|
|
entrypoint = runtime.DefaultEntrypoint(lang)
|
|
}
|
|
route := strings.TrimSpace(r.FormValue("route"))
|
|
if route == "" {
|
|
nsShort := ns
|
|
if len(nsShort) > 12 {
|
|
nsShort = nsShort[len(nsShort)-12:]
|
|
}
|
|
route = "/" + nsShort + "/" + name
|
|
}
|
|
if !strings.HasPrefix(route, "/") {
|
|
route = "/" + route
|
|
}
|
|
methods := normalizeMethods(strings.Split(r.FormValue("methods"), ","))
|
|
timeout := normalizeFunctionTimeout(0)
|
|
if tv := r.FormValue("timeout"); tv != "" {
|
|
if n, err := strconv.ParseInt(tv, 10, 64); err == nil {
|
|
timeout = normalizeFunctionTimeout(n)
|
|
}
|
|
}
|
|
|
|
// Загружаем архив в storagesvc
|
|
deploySpec, uploadErr := s.buildDeploySpec(ctx, archiveBytes)
|
|
if uploadErr != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload archive: %v", uploadErr))
|
|
return
|
|
}
|
|
|
|
pkgName := name + "-pkg"
|
|
triggerName := name + "-route"
|
|
|
|
pkg := &unstructured.Unstructured{Object: map[string]any{
|
|
"apiVersion": "fission.io/v1",
|
|
"kind": "Package",
|
|
"metadata": map[string]any{"name": pkgName, "namespace": ns},
|
|
"spec": map[string]any{
|
|
"deployment": deploySpec,
|
|
"environment": map[string]any{"name": envName, "namespace": ns},
|
|
"source": map[string]any{},
|
|
},
|
|
}}
|
|
|
|
now := time.Now().UTC()
|
|
fnAnnotations := map[string]any{
|
|
"fission-console/language": lang,
|
|
fissionSourceTypeAnnotation: "archive",
|
|
functionCreatedAtAnnotation: now.Format(time.RFC3339),
|
|
functionUpdatedAtAnnotation: now.Format(time.RFC3339),
|
|
}
|
|
if archiveFilenameCreate != "" {
|
|
fnAnnotations["fission-console/archive-filename"] = archiveFilenameCreate
|
|
}
|
|
if ttl := r.FormValue("ttl"); ttl != "" {
|
|
if expiresAt, ttlErr := parseTTL(ttl); ttlErr == nil {
|
|
fnAnnotations["fission-console/expires-at"] = expiresAt.UTC().Format(time.RFC3339)
|
|
}
|
|
}
|
|
|
|
methodValues := make([]any, 0, len(methods))
|
|
for _, m := range methods {
|
|
methodValues = append(methodValues, m)
|
|
}
|
|
|
|
fn := &unstructured.Unstructured{Object: map[string]any{
|
|
"apiVersion": "fission.io/v1",
|
|
"kind": "Function",
|
|
"metadata": map[string]any{"name": name, "namespace": ns, "annotations": fnAnnotations},
|
|
"spec": map[string]any{
|
|
"environment": map[string]any{"name": envName, "namespace": ns},
|
|
"package": map[string]any{"packageref": map[string]any{"name": pkgName, "namespace": ns}},
|
|
"InvokeStrategy": map[string]any{
|
|
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
|
|
"StrategyType": "execution",
|
|
},
|
|
"functionTimeout": timeout,
|
|
},
|
|
}}
|
|
if entrypoint != "" {
|
|
_ = unstructured.SetNestedField(fn.Object, entrypoint, "spec", "package", "functionName")
|
|
}
|
|
|
|
trigger := &unstructured.Unstructured{Object: map[string]any{
|
|
"apiVersion": "fission.io/v1",
|
|
"kind": "HTTPTrigger",
|
|
"metadata": map[string]any{"name": triggerName, "namespace": ns},
|
|
"spec": map[string]any{
|
|
"functionref": map[string]any{"name": name, "type": "name"},
|
|
"relativeurl": route,
|
|
"methods": methodValues,
|
|
},
|
|
}}
|
|
|
|
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", name))
|
|
return
|
|
}
|
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err))
|
|
return
|
|
}
|
|
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil {
|
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
|
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create function: %v", err))
|
|
return
|
|
}
|
|
if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, trigger, metav1.CreateOptions{}); err != nil {
|
|
_ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{})
|
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
|
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create trigger: %v", err))
|
|
return
|
|
}
|
|
|
|
writeAnyJSON(w, http.StatusCreated, map[string]any{
|
|
"name": name,
|
|
"namespace": ns,
|
|
"environment": envName,
|
|
"route": route,
|
|
"source_type": "archive",
|
|
})
|
|
}
|
|
|
|
// handleUpdateFunctionArchive обновляет функцию из загруженного zip-архива (multipart/form-data).
|
|
// Поля формы: timeout (optional), entrypoint (optional). Файловое поле: archive (.zip).
|
|
// Создаёт новый Package (новое имя) — чтобы executor сбросил кэш function service.
|
|
func (s *Server) handleUpdateFunctionArchive(w http.ResponseWriter, r *http.Request, name string) {
|
|
if err := r.ParseMultipartForm(maxArchiveUploadSize); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("parse multipart form: %v", err))
|
|
return
|
|
}
|
|
|
|
f, _, err := r.FormFile("archive")
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("archive file required: %v", err))
|
|
return
|
|
}
|
|
defer f.Close()
|
|
archiveBytes, err := io.ReadAll(io.LimitReader(f, maxArchiveUploadSize))
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read archive: %v", err))
|
|
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
|
|
}
|
|
|
|
oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name")
|
|
envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name")
|
|
|
|
newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36)
|
|
deploySpec, uploadErr := s.buildDeploySpec(ctx, archiveBytes)
|
|
if uploadErr != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("upload archive: %v", uploadErr))
|
|
return
|
|
}
|
|
|
|
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": deploySpec,
|
|
"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
|
|
}
|
|
|
|
// Обновляем timeout если задан
|
|
timeout := normalizeFunctionTimeout(0)
|
|
if tv := r.FormValue("timeout"); tv != "" {
|
|
if n, err := strconv.ParseInt(tv, 10, 64); err == nil {
|
|
timeout = normalizeFunctionTimeout(n)
|
|
}
|
|
}
|
|
_ = unstructured.SetNestedField(fn.Object, timeout, "spec", "functionTimeout")
|
|
|
|
// Обновляем entrypoint если передан
|
|
if ep := strings.TrimSpace(r.FormValue("entrypoint")); ep != "" {
|
|
_ = unstructured.SetNestedField(fn.Object, ep, "spec", "package", "functionName")
|
|
}
|
|
|
|
fnAnnotations := fn.GetAnnotations()
|
|
if fnAnnotations == nil {
|
|
fnAnnotations = map[string]string{}
|
|
}
|
|
fnAnnotations[fissionSourceTypeAnnotation] = "archive"
|
|
fnAnnotations[functionUpdatedAtAnnotation] = time.Now().UTC().Format(time.RFC3339)
|
|
if fh, fhErr := r.MultipartForm.File["archive"]; fhErr == false || len(fh) > 0 {
|
|
if files := r.MultipartForm.File["archive"]; len(files) > 0 && files[0].Filename != "" {
|
|
fnAnnotations["fission-console/archive-filename"] = files[0].Filename
|
|
}
|
|
}
|
|
fn.SetAnnotations(fnAnnotations)
|
|
|
|
if err := unstructured.SetNestedField(fn.Object, map[string]any{
|
|
"name": newPkgName,
|
|
"namespace": ns,
|
|
"resourceversion": createdPkg.GetResourceVersion(),
|
|
}, "spec", "package", "packageref"); err != nil {
|
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
|
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set packageref: %v", err))
|
|
return
|
|
}
|
|
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil {
|
|
_ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{})
|
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function: %v", err))
|
|
return
|
|
}
|
|
|
|
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,
|
|
"source_type": "archive",
|
|
})
|
|
}
|