diff --git a/console/cmd/server/main.go b/console/cmd/server/main.go index 250ada3..fd22dec 100644 --- a/console/cmd/server/main.go +++ b/console/cmd/server/main.go @@ -62,7 +62,7 @@ func main() { // Запускаем фоновые горутины: reaper истёкших функций nsm := srv.NSManager() nsm.StartExpiryReaper(envDurationDefault("REAPER_INTERVAL", 5*time.Minute)) - // StartNSReconciler удалён — за FISSION_RESOURCE_NAMESPACES теперь отвечает Layer 1 NSWatcher + nsm.StartNSReconciler(envDurationDefault("NS_RECONCILE_INTERVAL", 30*time.Second)) httpServer := &http.Server{ Addr: ":" + port, diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index 815ef40..883701f 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -52,7 +52,7 @@ spec: serviceAccountName: fission-console containers: - name: console - image: naeel/fission-console:v1.3.40 + image: naeel/fission-console:v1.3.45 imagePullPolicy: Always ports: - containerPort: 8090 diff --git a/console/internal/api/explain_archive.go b/console/internal/api/explain_archive.go index 66ef623..40c9f61 100644 --- a/console/internal/api/explain_archive.go +++ b/console/internal/api/explain_archive.go @@ -3,13 +3,13 @@ package api import ( "archive/zip" "bytes" + "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" - "context" ) const ( diff --git a/console/internal/api/handlers.go b/console/internal/api/handlers.go index c2834cc..d2f98e8 100644 --- a/console/internal/api/handlers.go +++ b/console/internal/api/handlers.go @@ -36,6 +36,12 @@ var validFuncName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) // Выше — не имеет смысла для inline функции; лучше использовать Package с URL. const maxCodeSize = 1 << 20 +// maxArchiveUploadSize — максимальный размер zip-архива при загрузке функции (32 MB). +const maxArchiveUploadSize = 32 << 20 + +// fissionSourceTypeAnnotation — аннотация на Function, хранит тип источника: "code" или "archive". +const fissionSourceTypeAnnotation = "fission-console/source-type" + // defaultFunctionInvokeTimeout совпадает с дефолтом Fission для spec.functionTimeout. const defaultFunctionInvokeTimeout = 60 * time.Second @@ -304,6 +310,12 @@ func (s *Server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) { return } + // /functions/:name/archive — обновление через zip-архив + if len(parts) == 2 && parts[1] == "archive" && r.Method == http.MethodPut { + s.handleUpdateFunctionArchive(w, r, name) + return + } + // /functions/:name/invoke — вызов функции if len(parts) == 2 && parts[1] == "invoke" && r.Method == http.MethodPost { s.handleInvokeFunction(w, r, name) @@ -321,6 +333,13 @@ func (s *Server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { ns := s.userNS(r) + // Поддерживаем два формата: JSON (код) и multipart/form-data (архив). + isArchiveUpload := strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") + if isArchiveUpload { + s.handleCreateFunctionFromArchive(w, r, ns) + return + } + var req model.CreateFunctionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err)) @@ -456,7 +475,8 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { // Парсим TTL ДО создания K8s ресурсов — невалидный TTL не оставляет мусор fnAnnotations := map[string]any{ - "fission-console/language": req.Language, + "fission-console/language": req.Language, + fissionSourceTypeAnnotation: "code", } now := time.Now().UTC() fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339) @@ -590,6 +610,14 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name } } + // Читаем source-type аннотацию (code / archive) + sourceType := "code" + if ann := fn.GetAnnotations(); ann != nil { + if v := ann[fissionSourceTypeAnnotation]; v != "" { + sourceType = v + } + } + writeAnyJSON(w, http.StatusOK, map[string]any{ "name": name, "namespace": ns, @@ -600,6 +628,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name "created_at": functionTimestampResponse(fn)["created_at"], "updated_at": functionTimestampResponse(fn)["updated_at"], "code": code, + "source_type": sourceType, "route": route, "methods": methods, "raw": fn.Object, @@ -1217,3 +1246,287 @@ func normalizeMethods(in []string) []string { } return out } + +// 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, _, 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 + } + + 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 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}}, + "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). Файловое поле: archive (.zip). +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") + + fnAnnotations := fn.GetAnnotations() + if fnAnnotations == nil { + fnAnnotations = map[string]string{} + } + fnAnnotations[fissionSourceTypeAnnotation] = "archive" + fnAnnotations[functionUpdatedAtAnnotation] = time.Now().UTC().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 { + _ = 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", + }) +} diff --git a/console/internal/cloud/tenant.go b/console/internal/cloud/tenant.go index e70cc02..4eb4678 100644 --- a/console/internal/cloud/tenant.go +++ b/console/internal/cloud/tenant.go @@ -3,6 +3,8 @@ package cloud import ( "context" "log" + "os" + "sort" "strings" "sync" "time" @@ -10,6 +12,7 @@ import ( 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/client-go/dynamic" "fission-console/internal/fission" @@ -140,6 +143,171 @@ func (m *NSManager) provisionNamespace(ctx context.Context, ns string) error { return nil } +var deployGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} + +// fissionSystemNS возвращает namespace где живут компоненты Fission (router, executor, ...). +func fissionSystemNS() string { + if ns := os.Getenv("FISSION_SYSTEM_NAMESPACE"); ns != "" { + return ns + } + return "fission" +} + +// StartNSReconciler запускает фоновый goroutine который синхронизирует FISSION_RESOURCE_NAMESPACES +// в router и executor со всеми реально существующими пользовательскими namespace-ами. +// +// Почему reconciler, а не прямой патч в EnsureUserNS: +// - Прямой патч → rolling restart при каждом новом логине → cold start для всех +// - Reconciler батчит изменения, не делает лишних патчей если список не изменился +// - Автоматически убирает ghost namespace-ы (удалённые вручную через kubectl) +func (m *NSManager) StartNSReconciler(interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + log.Printf("cloud.NSReconciler: started, interval=%v", interval) + for range ticker.C { + m.reconcileNSList() + } + }() +} + +// reconcileNSList синхронизирует FISSION_RESOURCE_NAMESPACES в router и executor. +// Читает все Active namespace-ы с меткой managed-by=fission-console, +// сравнивает с текущим значением в router, патчит только если есть разница. +func (m *NSManager) reconcileNSList() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + sysNS := fissionSystemNS() + + // Шаг 1: реальные namespace-ы с нашей меткой (только Active) + nsList, err := m.dyn.Resource(fission.NamespaceGVR).List(ctx, metav1.ListOptions{ + LabelSelector: "managed-by=fission-console", + }) + if err != nil { + log.Printf("cloud.NSReconciler: list namespaces: %v", err) + return + } + desired := map[string]struct{}{"default": {}} + for _, ns := range nsList.Items { + phase, _, _ := unstructured.NestedString(ns.Object, "status", "phase") + if phase == "Active" { + desired[ns.GetName()] = struct{}{} + } + } + + // Шаг 2: текущее значение из router (источник истины) + routerDep, err := m.dyn.Resource(deployGVR).Namespace(sysNS).Get(ctx, "router", metav1.GetOptions{}) + if err != nil { + log.Printf("cloud.NSReconciler: get router deployment: %v", err) + return + } + currentVal := currentFissionResourceNS(routerDep) + + // Шаг 3: сравниваем + 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 + } + + // Шаг 4: строим новое значение + parts := make([]string, 0, len(desired)) + for ns := range desired { + parts = append(parts, ns) + } + sort.Strings(parts) + newVal := strings.Join(parts, ",") + + // Шаг 5: патчим router и executor + for _, depName := range []string{"router", "executor"} { + if patchErr := m.patchDeployFissionNS(ctx, sysNS, depName, newVal); patchErr != nil { + log.Printf("cloud.NSReconciler: patch %s: %v", depName, patchErr) + } + } + log.Printf("cloud.NSReconciler: synced FISSION_RESOURCE_NAMESPACES: %q → %q", currentVal, newVal) +} + +// currentFissionResourceNS читает текущий FISSION_RESOURCE_NAMESPACES из deployment. +func currentFissionResourceNS(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" +} + +// patchDeployFissionNS обновляет FISSION_RESOURCE_NAMESPACES в указанном deployment. +func (m *NSManager) patchDeployFissionNS(ctx context.Context, sysNS, depName, newVal string) error { + dep, err := m.dyn.Resource(deployGVR).Namespace(sysNS).Get(ctx, depName, metav1.GetOptions{}) + if err != nil { + return err + } + containers, _, _ := unstructured.NestedSlice(dep.Object, "spec", "template", "spec", "containers") + if len(containers) == 0 { + return nil + } + cont, ok := containers[0].(map[string]any) + if !ok { + return nil + } + envs, _, _ := unstructured.NestedSlice(cont, "env") + updated := false + for i, e := range envs { + env, ok := e.(map[string]any) + if !ok { + continue + } + if env["name"] == "FISSION_RESOURCE_NAMESPACES" { + if env["value"] != newVal { + env["value"] = newVal + envs[i] = env + updated = true + } + break + } + } + if !updated { + envs = append(envs, map[string]any{"name": "FISSION_RESOURCE_NAMESPACES", "value": newVal}) + } + cont["env"] = envs + containers[0] = cont + if err := unstructured.SetNestedSlice(dep.Object, containers, "spec", "template", "spec", "containers"); err != nil { + return err + } + _, err = m.dyn.Resource(deployGVR).Namespace(sysNS).Update(ctx, dep, metav1.UpdateOptions{}) + return err +} + // StartExpiryReaper запускает фоновый goroutine для удаления функций с истёкшим TTL. // Interval — как часто проверять. Рекомендуемое значение: 5 минут. func (m *NSManager) StartExpiryReaper(interval time.Duration) { diff --git a/console/ui/index.html b/console/ui/index.html index 152ca35..c804f51 100644 --- a/console/ui/index.html +++ b/console/ui/index.html @@ -100,7 +100,7 @@
NUBES
FISSION CONSOLE
-
v1.3.40
+
v1.3.45
@@ -400,7 +400,7 @@
- v1.3.40 + v1.3.45
diff --git a/console/ui/js/functions.js b/console/ui/js/functions.js index 73b93e8..85fd5bb 100644 --- a/console/ui/js/functions.js +++ b/console/ui/js/functions.js @@ -167,15 +167,34 @@ async function submitCreate() { const lang = document.getElementById('c-lang').value.trim(); if (!lang) throw new Error('language is required'); - await requestJSON(API_BASE + '/functions', 'POST', { - name: name, - language: lang, - entrypoint: document.getElementById('c-entry').value.trim(), - route: document.getElementById('c-route').value.trim(), - methods: parseMethods(document.getElementById('c-methods').value), - timeout: parseTimeout(document.getElementById('c-timeout').value), - code: document.getElementById('c-code').value - }); + // Определяем режим: archiveArea видна → archive mode + var archiveArea = document.getElementById('c-archive-area'); + var isArchiveMode = archiveArea && archiveArea.style.display !== 'none'; + var archiveFile = isArchiveMode ? document.getElementById('c-archive-file').files[0] : null; + + if (isArchiveMode && archiveFile) { + // Отправляем multipart/form-data с архивом + var fd = new FormData(); + fd.append('name', name); + fd.append('language', lang); + fd.append('entrypoint', document.getElementById('c-entry').value.trim()); + fd.append('route', document.getElementById('c-route').value.trim()); + fd.append('methods', document.getElementById('c-methods').value.trim()); + fd.append('timeout', String(parseTimeout(document.getElementById('c-timeout').value))); + fd.append('archive', archiveFile); + var resp = await fetch(API_BASE + '/functions', { method: 'POST', headers: authHeaders(), body: fd }); + if (!resp.ok) { var e = await resp.json().catch(() => ({})); throw new Error(e.error || resp.statusText); } + } else { + await requestJSON(API_BASE + '/functions', 'POST', { + name: name, + language: lang, + entrypoint: document.getElementById('c-entry').value.trim(), + route: document.getElementById('c-route').value.trim(), + methods: parseMethods(document.getElementById('c-methods').value), + timeout: parseTimeout(document.getElementById('c-timeout').value), + code: document.getElementById('c-code').value + }); + } try { await syncScheduleForFunction(name, 'c'); @@ -208,6 +227,16 @@ async function openEdit(name) { document.getElementById('e-entry').value = fn.entrypoint || ''; document.getElementById('e-timeout').value = String(fn.timeout || 60); document.getElementById('e-code').value = fn.code || ''; + + // Сбрасываем режим: source_type из API (code / archive) + if (fn.source_type === 'archive') { + setCodeMode('e', 'archive'); + } else { + setCodeMode('e', 'code'); + } + var archiveInfo = document.getElementById('e-archive-info'); + if (archiveInfo) archiveInfo.textContent = ''; + var schedule = timeTriggerByFn(name); document.getElementById('e-schedule-enabled').checked = !!schedule; document.getElementById('e-cron').value = (schedule && schedule.spec && schedule.spec.cron) || ''; @@ -244,10 +273,26 @@ async function submitEdit() { const progress = startTimedStatus('Сохраняем код...', 'Сохранение кода...', explainDelay); try { const name = S.currentEdit.name; - await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', { - code: document.getElementById('e-code').value, - timeout: parseTimeout(document.getElementById('e-timeout').value) - }); + + // Определяем режим + var archiveArea = document.getElementById('e-archive-area'); + var isArchiveMode = archiveArea && archiveArea.style.display !== 'none'; + var archiveFile = isArchiveMode ? document.getElementById('e-archive-file').files[0] : null; + + if (isArchiveMode && archiveFile) { + // Обновляем через архив + var fd = new FormData(); + fd.append('timeout', String(parseTimeout(document.getElementById('e-timeout').value))); + fd.append('archive', archiveFile); + var resp = await fetch(API_BASE + '/functions/' + encodeURIComponent(name) + '/archive', + { method: 'PUT', headers: authHeaders(), body: fd }); + if (!resp.ok) { var e = await resp.json().catch(() => ({})); throw new Error(e.error || resp.statusText); } + } else { + await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', { + code: document.getElementById('e-code').value, + timeout: parseTimeout(document.getElementById('e-timeout').value) + }); + } await syncScheduleForFunction(name, 'e'); closeEdit(); diff --git a/doc/descriptions/archive-and-edit-modal-flow.md b/doc/descriptions/archive-and-edit-modal-flow.md new file mode 100644 index 0000000..ebd92ca --- /dev/null +++ b/doc/descriptions/archive-and-edit-modal-flow.md @@ -0,0 +1,239 @@ +# Archive Upload и Edit Modal — описание логики (v1.3.45, 2026-05-04) + +## Контекст + +До v1.3.45 функции создавались только через ввод кода (JSON поле `code`). +Edit modal при открытии не сбрасывал режим (code/archive) — показывал то что было +выбрано ранее. Создание и обновление через zip-архив не поддерживалось вовсе. + +--- + +## 1. Аннотация `fission-console/source-type` + +Добавлена новая аннотация на Kubernetes-объекте `Function`: + +``` +fission-console/source-type: "code" | "archive" +``` + +Ставится в момент создания функции и обновляется при каждом сохранении. +Хранится в `metadata.annotations` Function объекта в K8s — персистентно. + +**Зачем:** позволяет при открытии edit modal знать каким способом была создана +функция, без анализа содержимого Package (которое не даёт надёжного ответа). + +--- + +## 2. Backend — создание функции + +### 2a. Определение режима (handlers.go `handleCreateFunction`) + +```go +isArchiveUpload := strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") +if isArchiveUpload { + s.handleCreateFunctionFromArchive(w, r, ns) + return +} +``` + +Если Content-Type начинается с `multipart/form-data` — передаём в новый хендлер. +Иначе — старый JSON-путь без изменений. + +### 2b. Создание через JSON-код (без изменений, кроме аннотации) + +В `fnAnnotations` добавлено: +```go +fissionSourceTypeAnnotation: "code", +``` +Все старые функции (созданные до v1.3.45) не имеют этой аннотации — API вернёт +`source_type: "code"` по умолчанию. + +### 2c. Создание через архив (`handleCreateFunctionFromArchive`) + +**Вход:** `multipart/form-data` с полями: +- `name`, `language`, `environment`, `entrypoint`, `route`, `methods`, `timeout`, `ttl` +- `archive` — файл `.zip` + +**Алгоритм:** +1. `r.ParseMultipartForm(32MB)` — парсим форму +2. Валидация `name` по regexp `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, max 57 символов +3. Если задан `language` — через `fission.EnsureEnvironment` создаём/получаем Environment +4. `r.FormFile("archive")` → `io.ReadAll(io.LimitReader(f, 32MB))` — читаем архив +5. `s.buildDeploySpec(ctx, archiveBytes)` — загружаем в Fission storagesvc (S3), получаем + `{type: "url", url: "http://storagesvc/.../archive?id=..."}` или fallback на literal +6. Создаём Package с `spec.deployment = deploySpec` +7. Создаём Function с аннотацией `fission-console/source-type: archive` +8. Создаём HTTPTrigger +9. При ошибке на шаге 7 или 8 — откат (удаляем уже созданные объекты) + +**Ответ:** `201 Created` с JSON `{name, namespace, environment, route, source_type: "archive"}` + +--- + +## 3. Backend — обновление функции через архив + +### Новый endpoint: `PUT /console/api/functions/:name/archive` + +Зарегистрирован в `handleFunctionsAction`: +```go +if len(parts) == 2 && parts[1] == "archive" && r.Method == http.MethodPut { + s.handleUpdateFunctionArchive(w, r, name) + return +} +``` + +### `handleUpdateFunctionArchive` — алгоритм: + +1. Парсим multipart form (32MB лимит) +2. `r.FormFile("archive")` → читаем байты (лимит 32MB) +3. Получаем текущую Function из K8s +4. Запоминаем `oldPkgName` (для удаления после замены) +5. Генерируем `newPkgName = name + "-pkg-" + base36(unixMs)` — уникальное имя +6. Загружаем архив в storagesvc → создаём новый Package +7. Обновляем Function: `spec.package.packageref.name = newPkgName` +8. Обновляем аннотации: `source-type: archive`, `updated-at: now` +9. Обновляем `spec.functionTimeout` если передан `timeout` +10. Update Function в K8s +11. Удаляем старый Package (best-effort, ошибки игнорируем) + +**Почему создаём новый Package вместо обновления старого:** +Executor кэширует function service по `functionUid`. Изменение содержимого +существующего Package не сбрасывает кэш. Новое имя пакета гарантирует cache miss. +(Та же логика что в `handleUpdateFunctionCode`.) + +--- + +## 4. Backend — GET /functions/:name + +Добавлено поле `source_type` в ответ: + +```go +sourceType := "code" // default для старых функций без аннотации +if ann := fn.GetAnnotations(); ann != nil { + if v := ann[fissionSourceTypeAnnotation]; v != "" { + sourceType = v + } +} +``` + +Поле возвращается в JSON ответе: +```json +{ + "name": "my-func", + "code": "...", + "source_type": "archive", + ... +} +``` + +--- + +## 5. Frontend — submitCreate + +До v1.3.45 всегда отправлял JSON с полем `code`. + +Теперь определяет режим по видимости `c-archive-area`: + +```javascript +var archiveArea = document.getElementById('c-archive-area'); +var isArchiveMode = archiveArea && archiveArea.style.display !== 'none'; +var archiveFile = isArchiveMode ? document.getElementById('c-archive-file').files[0] : null; +``` + +**Если archive mode и файл выбран** → FormData: +```javascript +var fd = new FormData(); +fd.append('name', name); +fd.append('language', lang); +// ... остальные поля ... +fd.append('archive', archiveFile); +fetch(API_BASE + '/functions', { method: 'POST', headers: authHeaders(), body: fd }); +``` + +**Иначе** → старый JSON путь без изменений. + +--- + +## 6. Frontend — openEdit + +До v1.3.45 сбрасывал режим на основе `fn.code !== ''`, что было неправильно: +`extractPackageSourceCode` умеет доставать код даже из архивных функций через URL, +поэтому `fn.code` всегда был непустым. + +Теперь использует `fn.source_type`: +```javascript +if (fn.source_type === 'archive') { + setCodeMode('e', 'archive'); +} else { + setCodeMode('e', 'code'); +} +``` + +**Поведение:** +- Функция создана через код → открывается редактор кода с текущим кодом +- Функция создана из архива → открывается archive area с file input (пустым) +- Старые функции без аннотации → `source_type: "code"` по умолчанию → code mode + +--- + +## 7. Frontend — submitEdit + +До v1.3.45 всегда отправлял `PUT /functions/:name/code` с JSON. + +Теперь определяет режим аналогично submitCreate: + +```javascript +var archiveArea = document.getElementById('e-archive-area'); +var isArchiveMode = archiveArea && archiveArea.style.display !== 'none'; +var archiveFile = isArchiveMode ? document.getElementById('e-archive-file').files[0] : null; +``` + +**Если archive mode и файл выбран** → `PUT /functions/:name/archive` с FormData: +```javascript +fd.append('timeout', String(parseTimeout(...))); +fd.append('archive', archiveFile); +fetch(...'/archive', { method: 'PUT', headers: authHeaders(), body: fd }); +``` + +**Иначе** → старый `PUT /functions/:name/code` с JSON. + +**Важно:** если пользователь открыл функцию в archive mode но не выбрал новый файл — +`archiveFile` будет `null` и код уйдёт по JSON-пути (отправит пустой код). +Это expected behaviour: пользователь должен явно выбрать файл для обновления архива. + +--- + +## 8. Исправление версии в UI + +В `index.html` номер версии присутствует в **двух** местах: +- Строка 103: верхний правый угол (`
` в header) +- Строка 403: блок "Помощь" (`` в help modal) + +До v1.3.45 оба места обновлялись несинхронно — строка 103 отставала. +Начиная с v1.3.45 оба места обновляются одновременно через `multi_replace_string_in_file`. + +--- + +## 9. Проблема с imagePullPolicy + +При первом деплое v1.3.43 (после исправления версии) UI показывал `v1.3.40`. +Причина: `imagePullPolicy: IfNotPresent` — K8s имел образ `v1.3.43` в кэше +и не тянул обновлённый образ с тем же тегом. + +**Решение:** менять тег при каждой сборке (v1.3.43 → v1.3.44 → v1.3.45). +Это принципиальное правило: **никогда не пересобирать образ с тем же тегом**. + +--- + +## 10. Константы и аннотации (handlers.go) + +```go +const maxArchiveUploadSize = 32 << 20 // 32 MB +const fissionSourceTypeAnnotation = "fission-console/source-type" +``` + +Существующие константы не изменялись: +```go +const maxCodeSize = 1 << 20 // 1 MB — только для inline кода +const defaultFunctionInvokeTimeout = 60 * time.Second +```