fix: restore NSReconciler, archive upload create/edit, fix version label both places; v1.3.45
- tenant.go: restore StartNSReconciler (sync FISSION_RESOURCE_NAMESPACES every 30s) - main.go: call nsm.StartNSReconciler on startup - handlers.go: handleCreateFunctionFromArchive (multipart), handleUpdateFunctionArchive (PUT /archive) source-type annotation, source_type in GET response - functions.js: submitCreate/submitEdit archive mode, openEdit uses source_type - index.html: version label updated in BOTH places (line 103 and 403) to v1.3.45 - console.yaml: image v1.3.45 - doc: archive-and-edit-modal-flow.md
This commit is contained in:
@@ -62,7 +62,7 @@ func main() {
|
|||||||
// Запускаем фоновые горутины: reaper истёкших функций
|
// Запускаем фоновые горутины: reaper истёкших функций
|
||||||
nsm := srv.NSManager()
|
nsm := srv.NSManager()
|
||||||
nsm.StartExpiryReaper(envDurationDefault("REAPER_INTERVAL", 5*time.Minute))
|
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{
|
httpServer := &http.Server{
|
||||||
Addr: ":" + port,
|
Addr: ":" + port,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ spec:
|
|||||||
serviceAccountName: fission-console
|
serviceAccountName: fission-console
|
||||||
containers:
|
containers:
|
||||||
- name: console
|
- name: console
|
||||||
image: naeel/fission-console:v1.3.40
|
image: naeel/fission-console:v1.3.45
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8090
|
- containerPort: 8090
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package api
|
|||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"context"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ var validFuncName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
|
|||||||
// Выше — не имеет смысла для inline функции; лучше использовать Package с URL.
|
// Выше — не имеет смысла для inline функции; лучше использовать Package с URL.
|
||||||
const maxCodeSize = 1 << 20
|
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.
|
// defaultFunctionInvokeTimeout совпадает с дефолтом Fission для spec.functionTimeout.
|
||||||
const defaultFunctionInvokeTimeout = 60 * time.Second
|
const defaultFunctionInvokeTimeout = 60 * time.Second
|
||||||
|
|
||||||
@@ -304,6 +310,12 @@ func (s *Server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 — вызов функции
|
// /functions/:name/invoke — вызов функции
|
||||||
if len(parts) == 2 && parts[1] == "invoke" && r.Method == http.MethodPost {
|
if len(parts) == 2 && parts[1] == "invoke" && r.Method == http.MethodPost {
|
||||||
s.handleInvokeFunction(w, r, name)
|
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) {
|
func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
|
||||||
ns := s.userNS(r)
|
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
|
var req model.CreateFunctionRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
|
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 не оставляет мусор
|
// Парсим TTL ДО создания K8s ресурсов — невалидный TTL не оставляет мусор
|
||||||
fnAnnotations := map[string]any{
|
fnAnnotations := map[string]any{
|
||||||
"fission-console/language": req.Language,
|
"fission-console/language": req.Language,
|
||||||
|
fissionSourceTypeAnnotation: "code",
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339)
|
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{
|
writeAnyJSON(w, http.StatusOK, map[string]any{
|
||||||
"name": name,
|
"name": name,
|
||||||
"namespace": ns,
|
"namespace": ns,
|
||||||
@@ -600,6 +628,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
|
|||||||
"created_at": functionTimestampResponse(fn)["created_at"],
|
"created_at": functionTimestampResponse(fn)["created_at"],
|
||||||
"updated_at": functionTimestampResponse(fn)["updated_at"],
|
"updated_at": functionTimestampResponse(fn)["updated_at"],
|
||||||
"code": code,
|
"code": code,
|
||||||
|
"source_type": sourceType,
|
||||||
"route": route,
|
"route": route,
|
||||||
"methods": methods,
|
"methods": methods,
|
||||||
"raw": fn.Object,
|
"raw": fn.Object,
|
||||||
@@ -1217,3 +1246,287 @@ func normalizeMethods(in []string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package cloud
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +12,7 @@ import (
|
|||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
"k8s.io/client-go/dynamic"
|
"k8s.io/client-go/dynamic"
|
||||||
|
|
||||||
"fission-console/internal/fission"
|
"fission-console/internal/fission"
|
||||||
@@ -140,6 +143,171 @@ func (m *NSManager) provisionNamespace(ctx context.Context, ns string) error {
|
|||||||
return nil
|
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.
|
// StartExpiryReaper запускает фоновый goroutine для удаления функций с истёкшим TTL.
|
||||||
// Interval — как часто проверять. Рекомендуемое значение: 5 минут.
|
// Interval — как часто проверять. Рекомендуемое значение: 5 минут.
|
||||||
func (m *NSManager) StartExpiryReaper(interval time.Duration) {
|
func (m *NSManager) StartExpiryReaper(interval time.Duration) {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
<div class="nubes">NUBES</div>
|
<div class="nubes">NUBES</div>
|
||||||
<div class="product">FISSION CONSOLE</div>
|
<div class="product">FISSION CONSOLE</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.40</div>
|
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.45</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin:0;">
|
<div class="row" style="margin:0;">
|
||||||
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
|
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
|
||||||
@@ -400,7 +400,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions" style="justify-content:space-between; align-items:center;">
|
<div class="actions" style="justify-content:space-between; align-items:center;">
|
||||||
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.40</span>
|
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.45</span>
|
||||||
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+58
-13
@@ -167,15 +167,34 @@ async function submitCreate() {
|
|||||||
const lang = document.getElementById('c-lang').value.trim();
|
const lang = document.getElementById('c-lang').value.trim();
|
||||||
if (!lang) throw new Error('language is required');
|
if (!lang) throw new Error('language is required');
|
||||||
|
|
||||||
await requestJSON(API_BASE + '/functions', 'POST', {
|
// Определяем режим: archiveArea видна → archive mode
|
||||||
name: name,
|
var archiveArea = document.getElementById('c-archive-area');
|
||||||
language: lang,
|
var isArchiveMode = archiveArea && archiveArea.style.display !== 'none';
|
||||||
entrypoint: document.getElementById('c-entry').value.trim(),
|
var archiveFile = isArchiveMode ? document.getElementById('c-archive-file').files[0] : null;
|
||||||
route: document.getElementById('c-route').value.trim(),
|
|
||||||
methods: parseMethods(document.getElementById('c-methods').value),
|
if (isArchiveMode && archiveFile) {
|
||||||
timeout: parseTimeout(document.getElementById('c-timeout').value),
|
// Отправляем multipart/form-data с архивом
|
||||||
code: document.getElementById('c-code').value
|
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 {
|
try {
|
||||||
await syncScheduleForFunction(name, 'c');
|
await syncScheduleForFunction(name, 'c');
|
||||||
@@ -208,6 +227,16 @@ async function openEdit(name) {
|
|||||||
document.getElementById('e-entry').value = fn.entrypoint || '';
|
document.getElementById('e-entry').value = fn.entrypoint || '';
|
||||||
document.getElementById('e-timeout').value = String(fn.timeout || 60);
|
document.getElementById('e-timeout').value = String(fn.timeout || 60);
|
||||||
document.getElementById('e-code').value = fn.code || '';
|
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);
|
var schedule = timeTriggerByFn(name);
|
||||||
document.getElementById('e-schedule-enabled').checked = !!schedule;
|
document.getElementById('e-schedule-enabled').checked = !!schedule;
|
||||||
document.getElementById('e-cron').value = (schedule && schedule.spec && schedule.spec.cron) || '';
|
document.getElementById('e-cron').value = (schedule && schedule.spec && schedule.spec.cron) || '';
|
||||||
@@ -244,10 +273,26 @@ async function submitEdit() {
|
|||||||
const progress = startTimedStatus('Сохраняем код...', 'Сохранение кода...', explainDelay);
|
const progress = startTimedStatus('Сохраняем код...', 'Сохранение кода...', explainDelay);
|
||||||
try {
|
try {
|
||||||
const name = S.currentEdit.name;
|
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');
|
await syncScheduleForFunction(name, 'e');
|
||||||
closeEdit();
|
closeEdit();
|
||||||
|
|||||||
@@ -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: верхний правый угол (`<div>` в header)
|
||||||
|
- Строка 403: блок "Помощь" (`<span>` в 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
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user