diff --git a/LAYER_EXPLANATION_RU.md b/LAYER_EXPLANATION_RU.md new file mode 100644 index 0000000..3a7aeb0 --- /dev/null +++ b/LAYER_EXPLANATION_RU.md @@ -0,0 +1,59 @@ +# Слои работ по мультитенантности + +## Слой 1 — базовая поддержка мультитенантности в Fission + +Это внутренний фундамент. + +Простыми словами: +сам Fission научили нормально видеть и подхватывать новые namespace-ы в multi-tenant режиме. + +Что это значит: + +1. Новый namespace можно подключать динамически. +2. Не нужно копировать одну и ту же watcher-логику в разные части Fission. +3. Внутренняя логика namespace lifecycle приведена к общему виду. + +Ещё проще: + +- слой 1 = научили систему работать + +## Слой 1.5 — доводка надёжности мультитенантности + +Это не про новый функционал, а про устойчивость. + +Простыми словами: +нужно убедиться, что мультитенантность не глючит в сложных сценариях. + +Что сюда относится: + +1. Проверка гонок и рассинхрона между компонентами. +2. Проверка повторных событий и странных watcher-сценариев. +3. Проверка удаления namespace-ов. +4. Проверка стабильности под нагрузкой и в краевых случаях. + +Ещё проще: + +- слой 1.5 = делаем, чтобы работало надёжно + +## Слой 2 — внешний API / status / console слой + +Это уже внешний уровень поверх готового внутреннего foundation. + +Простыми словами: +нужно вывести состояние мультитенантности наружу так, чтобы это видел API, console или status/debug endpoint. + +Что сюда относится: + +1. Показать состояние namespace-ов наружу. +2. Дать read-only API или status/debug surface. +3. Связать console/API с уже готовым внутренним foundation. + +Ещё проще: + +- слой 2 = показываем и используем это снаружи + +## Краткая формула + +- Слой 1: научили работать +- Слой 1.5: делаем стабильным +- Слой 2: выводим наружу в API и console \ No newline at end of file diff --git a/cleanup_test_namespaces.sh b/cleanup_test_namespaces.sh new file mode 100755 index 0000000..c5e47a5 --- /dev/null +++ b/cleanup_test_namespaces.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +SSH_ARGS=( + -i ~/.ssh/naeel_vm_id_ed25519 + -o StrictHostKeyChecking=no + -o ConnectTimeout=10 + naeel@5.172.178.213 +) + +kube_exec() { + if [ "${KUBECTL_DIRECT:-0}" = "1" ]; then + kubectl "$@" + else + ssh "${SSH_ARGS[@]}" "kubectl $*" + fi +} + +namespace_for_sub() { + printf '%s' "$1" | sha256sum | awk '{print "fission-" substr($1, 1, 16)}' +} + +delete_ns() { + local ns="$1" + [ -n "$ns" ] || return 0 + if [ "${KUBECTL_DIRECT:-0}" = "1" ]; then + kubectl delete namespace "$ns" --wait=false >/dev/null 2>&1 || true + else + ssh "${SSH_ARGS[@]}" "kubectl delete namespace ${ns} --wait=false >/dev/null 2>&1 || true" + fi + echo "cleanup namespace: ${ns}" +} + +delete_all_console_managed() { + local ns_list + if [ "${KUBECTL_DIRECT:-0}" = "1" ]; then + ns_list=$(kubectl get ns -l managed-by=fission-console -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null || true) + else + ns_list=$(ssh "${SSH_ARGS[@]}" ' +set -e +kubectl get ns -l managed-by=fission-console -o jsonpath="{range .items[*]}{.metadata.name}{"\n"}{end}" 2>/dev/null || true +' ) + fi + printf '%s\n' "$ns_list" | while IFS= read -r ns; do + [ -n "$ns" ] || continue + delete_ns "$ns" + done +} + +if [ "$#" -eq 0 ]; then + echo "usage: $0 SUB [SUB ...] | --all-console-managed" >&2 + exit 1 +fi + +if [ "$1" = "--all-console-managed" ]; then + delete_all_console_managed + exit 0 +fi + +printf '%s\n' "$@" | awk 'NF && !seen[$0]++' | while IFS= read -r sub; do + delete_ns "$(namespace_for_sub "$sub")" +done \ No newline at end of file diff --git a/console/Dockerfile b/console/Dockerfile index 78cfe37..2d11600 100644 --- a/console/Dockerfile +++ b/console/Dockerfile @@ -6,7 +6,7 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o fission-console ./cmd/server/ FROM alpine:3.20 -RUN apk add --no-cache ca-certificates nodejs python3 ruby perl php83 +RUN apk add --no-cache ca-certificates nodejs python3 ruby php83 COPY --from=builder /build/fission-console /fission-console EXPOSE 8090 ENTRYPOINT ["/fission-console"] diff --git a/console/internal/api/ai_check.go b/console/internal/api/ai_check.go index 8a56941..0de2144 100644 --- a/console/internal/api/ai_check.go +++ b/console/internal/api/ai_check.go @@ -22,7 +22,7 @@ import ( // // Почему реальные линтеры, а не LLM: // LLM часто "исправляет" валидный код и врёт о наличии ошибок. -// node --check / python3 -m py_compile / ruby -c / php -l / perl -c — детерминированы и надёжны. +// node --check / python3 -m py_compile / ruby -c / php -l — детерминированы и надёжны. // Go использует go/parser прямо в процессе — без внешних команд, быстро. func (s *Server) handleAICheck(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -54,7 +54,6 @@ func (s *Server) handleAICheck(w http.ResponseWriter, r *http.Request) { "python": {ext: ".py", cmd: []string{"python3", "-m", "py_compile"}}, "ruby": {ext: ".rb", cmd: []string{"ruby", "-c"}}, "php": {ext: ".php", cmd: []string{"php", "-l"}}, - "perl": {ext: ".pl", cmd: []string{"perl", "-c"}}, "go": {ext: ".go", cmd: nil}, // go проверяется через go/parser в процессе } diff --git a/console/internal/api/handlers.go b/console/internal/api/handlers.go index bb8dd21..50706fa 100644 --- a/console/internal/api/handlers.go +++ b/console/internal/api/handlers.go @@ -35,6 +35,19 @@ var validFuncName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) // Выше — не имеет смысла для inline функции; лучше использовать Package с URL. const maxCodeSize = 1 << 20 +func buildDeployArchive(lang, code string) ([]byte, error) { + switch lang { + case "nodejs": + return runtime.BuildJSDeployZip(code) + case "php": + return runtime.BuildScriptZip(code, "main.php") + case "ruby": + return runtime.BuildScriptZip(code, "handler.rb") + default: + return []byte(code), nil + } +} + // handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции. // GET → список всех функций, POST → создать новую. func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) { @@ -212,17 +225,10 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { "buildcommand": "build", } } else { - var deployBytes []byte - if req.Language == "nodejs" { - zipBytes, zipErr := runtime.BuildJSDeployZip(req.Code) - if zipErr != nil { - writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build nodejs archive: %v", zipErr)) - return - } - deployBytes = zipBytes - } else { - // Python, PHP, Ruby, Perl — код передаётся как есть в deployment.literal - deployBytes = []byte(req.Code) + deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code) + if archiveErr != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr)) + return } pkgSpec = map[string]any{ "deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)}, @@ -429,16 +435,10 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request // Определяем язык из аннотации — нужен для правильной упаковки lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language") - var deployBytes []byte - if lang == "nodejs" { - zipBytes, zipErr := runtime.BuildJSDeployZip(req.Code) - if zipErr != nil { - writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build nodejs archive: %v", zipErr)) - return - } - deployBytes = zipBytes - } else { - deployBytes = []byte(req.Code) + deployBytes, archiveErr := buildDeployArchive(lang, req.Code) + if archiveErr != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr)) + return } literal := base64.StdEncoding.EncodeToString(deployBytes) diff --git a/console/internal/api/ns_status.go b/console/internal/api/ns_status.go index d82d9e7..12a730b 100644 --- a/console/internal/api/ns_status.go +++ b/console/internal/api/ns_status.go @@ -4,7 +4,6 @@ import ( "context" "net/http" "os" - "strings" "time" "fission-console/internal/fission" @@ -14,72 +13,95 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" ) +var requiredBootstrapServiceAccounts = []string{"fission-fetcher", "fission-builder"} + +var requiredBootstrapRoleBindings = []string{ + "fission-executor-user-ns", + "fission-router-user-ns", + "fission-buildermgr-user-ns", + "fission-kubewatcher-user-ns", + "fission-timer-user-ns", + "fission-fetcher-system-user-ns", + "fission-builder-system-user-ns", + "fission-fetcher-local-user-ns", + "fission-builder-local-user-ns", +} + +type namespacePreparationStatus struct { + ManagedByConsole bool `json:"managedByConsole"` + WatcherLabel bool `json:"watcherLabel"` + ServiceAccounts map[string]bool `json:"serviceAccounts"` + RoleBindings map[string]bool `json:"roleBindings"` +} + +func (s namespacePreparationStatus) Ready() bool { + if !s.ManagedByConsole || !s.WatcherLabel { + return false + } + for _, ok := range s.ServiceAccounts { + if !ok { + return false + } + } + for _, ok := range s.RoleBindings { + if !ok { + return false + } + } + return true +} + +type controlPlaneServiceStatus struct { + Ready bool `json:"ready"` + PodCount int `json:"podCount"` + ReadyPods []string `json:"readyPods"` +} + +type controlPlaneStatus struct { + Namespace string `json:"namespace"` + Ready bool `json:"ready"` + Executor controlPlaneServiceStatus `json:"executor"` + Router controlPlaneServiceStatus `json:"router"` +} + // handleNSStatus возвращает статус инициализации пользовательского namespace. // Используется UI для отображения прогресса при первом входе нового пользователя. // // Три стадии: // 1. Namespace существует и Active -// 2. Namespace добавлен в FISSION_RESOURCE_NAMESPACES executor deployment -// 3. Executor pod Running + Ready (готов принимать функции) +// 2. Namespace подготовлен для Layer 1 NSWatcher: label + bootstrap RBAC/SA +// 3. Fission control-plane готов: executor и router Running + Ready func (s *Server) handleNSStatus(w http.ResponseWriter, r *http.Request) { ns := s.userNS(r) ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() - fissionNS := os.Getenv("FISSION_SYSTEM_NAMESPACE") - if fissionNS == "" { - fissionNS = "fission" - } - type stageInfo struct { Name string `json:"name"` Done bool `json:"done"` } stages := []stageInfo{ {Name: "Создание пространства имён"}, - {Name: "Регистрация в Fission"}, - {Name: "Прогрев окружений"}, + {Name: "Подготовка namespace для Fission"}, + {Name: "Готовность control-plane"}, } - // Stage 1: namespace существует и Active nsObj, err := s.dyn.Resource(fission.NamespaceGVR).Get(ctx, ns, metav1.GetOptions{}) + var preparation namespacePreparationStatus + control := controlPlaneStatus{Namespace: fissionSystemNamespace()} if err == nil { phase, _, _ := unstructured.NestedString(nsObj.Object, "status", "phase") stages[0].Done = phase == "Active" } - // Stage 2: namespace в FISSION_RESOURCE_NAMESPACES executor deployment if stages[0].Done { - execDep, err2 := s.dyn.Resource(fission.DeploymentGVR).Namespace(fissionNS).Get(ctx, "executor", metav1.GetOptions{}) - if err2 == nil { - stages[1].Done = nsInFissionEnv(execDep, ns) - } + preparation = s.namespacePreparationStatus(ctx, ns, nsObj) + stages[1].Done = preparation.Ready() } - // Stage 3: executor pod Running + Ready if stages[1].Done { - podGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} - podList, err3 := s.dyn.Resource(podGVR).Namespace(fissionNS).List(ctx, metav1.ListOptions{ - LabelSelector: "svc=executor", - }) - if err3 == nil { - for _, pod := range podList.Items { - phase, _, _ := unstructured.NestedString(pod.Object, "status", "phase") - if phase != "Running" { - continue - } - conditions, _, _ := unstructured.NestedSlice(pod.Object, "status", "conditions") - for _, c := range conditions { - cond, ok := c.(map[string]any) - if !ok { - continue - } - if cond["type"] == "Ready" && cond["status"] == "True" { - stages[2].Done = true - } - } - } - } + control = s.controlPlaneStatus(ctx) + stages[2].Done = control.Ready } ready := stages[0].Done && stages[1].Done && stages[2].Done @@ -89,32 +111,116 @@ func (s *Server) handleNSStatus(w http.ResponseWriter, r *http.Request) { }) } -// nsInFissionEnv проверяет что namespace ns содержится в FISSION_RESOURCE_NAMESPACES -// первого контейнера данного deployment. -func nsInFissionEnv(dep *unstructured.Unstructured, ns string) bool { - containers, _, _ := unstructured.NestedSlice(dep.Object, "spec", "template", "spec", "containers") - for _, c := range containers { - cont, ok := c.(map[string]any) +func (s *Server) handleNSDebug(w http.ResponseWriter, r *http.Request) { + ns := s.userNS(r) + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + nsObj, err := s.dyn.Resource(fission.NamespaceGVR).Get(ctx, ns, metav1.GetOptions{}) + labels := map[string]string{} + phase := "" + exists := err == nil + active := false + preparation := namespacePreparationStatus{} + if exists { + labels, _, _ = unstructured.NestedStringMap(nsObj.Object, "metadata", "labels") + phase, _, _ = unstructured.NestedString(nsObj.Object, "status", "phase") + active = phase == "Active" + preparation = s.namespacePreparationStatus(ctx, ns, nsObj) + } + control := s.controlPlaneStatus(ctx) + + writeAnyJSON(w, http.StatusOK, map[string]any{ + "namespace": ns, + "exists": exists, + "active": active, + "phase": phase, + "labels": labels, + "prepared": preparation.Ready(), + "preparation": preparation, + "controlPlane": control, + "namespaceReady": active && preparation.Ready() && control.Ready, + }) +} + +func (s *Server) namespacePreparationStatus(ctx context.Context, ns string, nsObj *unstructured.Unstructured) namespacePreparationStatus { + status := namespacePreparationStatus{ + ServiceAccounts: make(map[string]bool, len(requiredBootstrapServiceAccounts)), + RoleBindings: make(map[string]bool, len(requiredBootstrapRoleBindings)), + } + if nsObj != nil { + labels, _, _ := unstructured.NestedStringMap(nsObj.Object, "metadata", "labels") + status.ManagedByConsole = labels["managed-by"] == "fission-console" + status.WatcherLabel = labels["fission.io/managed"] == "true" + } + + saGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "serviceaccounts"} + for _, name := range requiredBootstrapServiceAccounts { + _, err := s.dyn.Resource(saGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + status.ServiceAccounts[name] = err == nil + } + + rbGVR := schema.GroupVersionResource{Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "rolebindings"} + for _, name := range requiredBootstrapRoleBindings { + _, err := s.dyn.Resource(rbGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + status.RoleBindings[name] = err == nil + } + + return status +} + +func (s *Server) controlPlaneStatus(ctx context.Context) controlPlaneStatus { + status := controlPlaneStatus{Namespace: fissionSystemNamespace()} + status.Executor = s.serviceStatus(ctx, status.Namespace, "executor") + status.Router = s.serviceStatus(ctx, status.Namespace, "router") + status.Ready = status.Executor.Ready && status.Router.Ready + return status +} + +func (s *Server) serviceStatus(ctx context.Context, namespace, service string) controlPlaneServiceStatus { + podGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} + podList, err := s.dyn.Resource(podGVR).Namespace(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: "svc=" + service, + }) + if err != nil { + return controlPlaneServiceStatus{} + } + + status := controlPlaneServiceStatus{PodCount: len(podList.Items)} + for _, pod := range podList.Items { + if podReady(&pod) { + status.Ready = true + status.ReadyPods = append(status.ReadyPods, pod.GetName()) + } + } + return status +} + +func fissionSystemNamespace() string { + ns := os.Getenv("FISSION_SYSTEM_NAMESPACE") + if ns == "" { + return "fission" + } + return ns +} + +func podReady(pod *unstructured.Unstructured) bool { + if pod == nil { + return false + } + phase, _, _ := unstructured.NestedString(pod.Object, "status", "phase") + if phase != "Running" { + return false + } + conditions, _, _ := unstructured.NestedSlice(pod.Object, "status", "conditions") + for _, c := range conditions { + cond, ok := c.(map[string]any) if !ok { continue } - envs, _, _ := unstructured.NestedSlice(cont, "env") - for _, e := range envs { - env, ok := e.(map[string]any) - if !ok { - continue - } - if env["name"] == "FISSION_RESOURCE_NAMESPACES" { - if v, ok := env["value"].(string); ok { - for _, p := range strings.Split(v, ",") { - if strings.TrimSpace(p) == ns { - return true - } - } - } - } + if cond["type"] == "Ready" && cond["status"] == "True" { + return true } - break // только первый контейнер } return false -} +} \ No newline at end of file diff --git a/console/internal/api/package.go b/console/internal/api/package.go index c580c4a..410906b 100644 --- a/console/internal/api/package.go +++ b/console/internal/api/package.go @@ -90,7 +90,7 @@ func decodeArchiveBytesToSource(decoded []byte) (string, error) { if len(decoded) == 0 { return "", io.ErrUnexpectedEOF } - // Если байты — валидный UTF-8, возвращаем напрямую (python, ruby, perl, php) + // Если байты — валидный UTF-8, возвращаем напрямую (python, ruby, php) if utf8.Valid(decoded) { return string(decoded), nil } @@ -112,7 +112,7 @@ func decodeZipSource(zipBytes []byte) (string, error) { } // Сначала ищем по приоритетным именам - preferred := []string{"main.py", "main.js", "main.go", "handler.go", "handler.js", "handler.py"} + preferred := []string{"main.py", "main.js", "main.go", "main.php", "handler.rb", "handler.pl", "handler.go", "handler.js", "handler.py"} for _, name := range preferred { for _, file := range reader.File { if strings.EqualFold(file.Name, name) { diff --git a/console/internal/api/server.go b/console/internal/api/server.go index e7ddf4b..6d73fe9 100644 --- a/console/internal/api/server.go +++ b/console/internal/api/server.go @@ -140,6 +140,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR))) mux.HandleFunc("/console/api/timetriggers", auth(s.handleList(fission.TimeTrigGVR))) mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus)) + mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug)) mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck)) // --- ai/ask feature (удалить строку чтобы выкосить роут) --- mux.HandleFunc("/console/api/ai/ask", auth(s.handleAIAsk)) diff --git a/console/internal/fission/environment.go b/console/internal/fission/environment.go index f1965b1..6c2bd33 100644 --- a/console/internal/fission/environment.go +++ b/console/internal/fission/environment.go @@ -82,7 +82,6 @@ func CleanupEnvironmentIfUnused(ctx context.Context, dyn dynamic.Interface, ns, // Вся Fission-специфичная схема изолирована здесь — при обновлении Fission меняем только тут. func buildLangEnvironment(name, ns string, def model.LangEnvDef) *unstructured.Unstructured { // Version по умолчанию 3 (V2 protocol с async entrypoint). - // Исключение: perl-env поддерживает только V1 protocol → version=1. envVersion := int64(3) if def.Version != 0 { envVersion = int64(def.Version) diff --git a/console/internal/model/types.go b/console/internal/model/types.go index 793c19e..1fe0d86 100644 --- a/console/internal/model/types.go +++ b/console/internal/model/types.go @@ -23,7 +23,7 @@ type UpdateCodeRequest struct { // LangEnvDef описывает Docker-образы для конкретного языка. // BuilderImage заполнен только для языков которым нужна компиляция (Go). -// Version: версия среды Fission (по умолчанию 3; perl использует 1 — нет поддержки async entrypoint). +// Version: версия среды Fission (по умолчанию 3). type LangEnvDef struct { Image string BuilderImage string @@ -32,15 +32,10 @@ type LangEnvDef struct { // LangEnvMap сопоставляет идентификатор языка (string) с описанием среды выполнения. // Ключ используется в createFunctionRequest.Language и как суффикс имени Environment. -// -// Почему perl Version=1: -// Fission Environment v3 требует поддержки async entrypoint (V2 protocol). -// ghcr.io/fission/perl-env поддерживает только V1 protocol → version=1. var LangEnvMap = map[string]LangEnvDef{ "python": {Image: "ghcr.io/fission/python-env"}, "nodejs": {Image: "ghcr.io/fission/node-env"}, "go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"}, "php": {Image: "ghcr.io/fission/php-env"}, "ruby": {Image: "ghcr.io/fission/ruby-env"}, - "perl": {Image: "ghcr.io/fission/perl-env", Version: 1}, } diff --git a/console/internal/runtime/entrypoint.go b/console/internal/runtime/entrypoint.go index b88ef77..9bd6c37 100644 --- a/console/internal/runtime/entrypoint.go +++ b/console/internal/runtime/entrypoint.go @@ -7,7 +7,6 @@ package runtime // - nodejs: "main" → имя экспортированной default-функции в main.js // - php: "main.php::handler" → имя файла + "::" + имя функции // - ruby: "handler" → имя метода в загруженном файле -// - perl: "handler" → имя функции в загруженном файле // - go: "Handler" → экспортированная Go-функция (с большой буквы) func DefaultEntrypoint(lang string) string { switch lang { @@ -18,8 +17,6 @@ func DefaultEntrypoint(lang string) string { return "main.php::handler" case "ruby": return "handler" - case "perl": - return "handler" case "go": // Go: экспортированная функция (заглавная) — go/plugin требует экспорт return "Handler" diff --git a/console/internal/runtime/helpers.go b/console/internal/runtime/helpers.go index d813b6b..91b2889 100644 --- a/console/internal/runtime/helpers.go +++ b/console/internal/runtime/helpers.go @@ -6,7 +6,7 @@ import ( ) // buildZip создаёт zip-архив с одним файлом fileName и содержимым content. -// Вспомогательная функция: используется в buildScriptZip (PHP/Ruby/Perl), +// Вспомогательная функция: используется в buildScriptZip (PHP/Ruby), // а также как основа для buildGoSourceZip и buildJSDeployZip. func buildZip(fileName string, content []byte) ([]byte, error) { var buf bytes.Buffer diff --git a/console/internal/runtime/nodejs.go b/console/internal/runtime/nodejs.go index 6a30c67..da791f1 100644 --- a/console/internal/runtime/nodejs.go +++ b/console/internal/runtime/nodejs.go @@ -22,6 +22,11 @@ import ( // Результат: zip с двумя файлами: // - package.json: {"type":"module"} // - main.js: ESM wrapper + инлайн пользовательский код через new Function +// +// Совместимость entrypoint: +// UI исторически отправлял entrypoint="handler", а backend по умолчанию использует +// entrypoint="main". Чтобы specialization не ломался из-за несовпадения, +// wrapper экспортирует обе точки входа: named exports main и handler, а также default. func BuildJSDeployZip(code string) ([]byte, error) { // Сериализуем пользовательский код в JSON строку чтобы безопасно инлайнить // в JavaScript-литерал — экранирует кавычки, переводы строк, спецсимволы. @@ -38,7 +43,7 @@ func BuildJSDeployZip(code string) ([]byte, error) { (new Function('module', 'exports', %s))(__mod, __mod.exports); const _fn = __mod.exports; -export default async function(ctx) { + async function __invoke(ctx) { const fn = typeof _fn === 'function' ? _fn : (_fn.default || _fn.handler || _fn.main); if (!fn) throw new Error('no exported function found in user code'); const result = await fn(ctx); @@ -46,6 +51,9 @@ export default async function(ctx) { if (typeof result.status !== 'undefined') return result; return { status: 200, ...result }; } + + export { __invoke as main, __invoke as handler }; + export default __invoke; `, string(codeJSON)) var buf bytes.Buffer diff --git a/console/internal/runtime/nodejs_test.go b/console/internal/runtime/nodejs_test.go new file mode 100644 index 0000000..4afeda9 --- /dev/null +++ b/console/internal/runtime/nodejs_test.go @@ -0,0 +1,50 @@ +package runtime + +import ( + "archive/zip" + "bytes" + "io" + "strings" + "testing" +) + +func TestBuildJSDeployZipExportsMainAndHandler(t *testing.T) { + zipBytes, err := BuildJSDeployZip(`module.exports = async function () { return { status: 200, body: "ok" }; }`) + if err != nil { + t.Fatalf("BuildJSDeployZip() error = %v", err) + } + + zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes))) + if err != nil { + t.Fatalf("zip.NewReader() error = %v", err) + } + + files := map[string]string{} + for _, file := range zr.File { + rc, openErr := file.Open() + if openErr != nil { + t.Fatalf("open %q: %v", file.Name, openErr) + } + content, readErr := io.ReadAll(rc) + _ = rc.Close() + if readErr != nil { + t.Fatalf("read %q: %v", file.Name, readErr) + } + files[file.Name] = string(content) + } + + if files["package.json"] != `{"type":"module"}` { + t.Fatalf("package.json = %q, want ESM marker", files["package.json"]) + } + + mainJS := files["main.js"] + if !strings.Contains(mainJS, `export { __invoke as main, __invoke as handler };`) { + t.Fatalf("main.js does not export both main and handler: %s", mainJS) + } + if !strings.Contains(mainJS, `export default __invoke;`) { + t.Fatalf("main.js does not export default invoke: %s", mainJS) + } + if !strings.Contains(mainJS, `_fn.default || _fn.handler || _fn.main`) { + t.Fatalf("main.js lost user export resolution: %s", mainJS) + } +} \ No newline at end of file diff --git a/console/internal/runtime/script.go b/console/internal/runtime/script.go index af26c6a..da631b7 100644 --- a/console/internal/runtime/script.go +++ b/console/internal/runtime/script.go @@ -1,7 +1,7 @@ package runtime // BuildScriptZip создаёт zip-архив с одним файлом fileName и содержимым code. -// Используется для PHP, Ruby, Perl — языков где среда Fission ожидает +// Используется для PHP и Ruby — языков где среда Fission ожидает // именованный файл (handler.rb, handler.pl, main.php и т.д.) внутри архива. // // Почему zip, а не просто literal: diff --git a/console/main.go b/console/main.go index 4c85be1..e901721 100644 --- a/console/main.go +++ b/console/main.go @@ -125,7 +125,6 @@ var langEnvMap = map[string]langEnvDef{ "go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"}, "php": {Image: "ghcr.io/fission/php-env"}, "ruby": {Image: "ghcr.io/fission/ruby-env"}, - "perl": {Image: "ghcr.io/fission/perl-env", Version: 1}, } type updateCodeRequest struct { @@ -862,7 +861,7 @@ export default async function(ctx) { } // buildScriptZip wraps code into a zip file with the given filename. -// Used for PHP, Ruby, Perl where the environment requires a named source file. +// Used for PHP and Ruby where the environment requires a named source file. func buildScriptZip(code, filename string) ([]byte, error) { var buf bytes.Buffer zw := zip.NewWriter(&buf) @@ -887,8 +886,6 @@ func defaultEntrypoint(lang string) string { return "main.php::handler" case "ruby": return "handler" - case "perl": - return "handler" case "go": return "Handler" default: @@ -2266,7 +2263,6 @@ func (s *server) handleAICheck(w http.ResponseWriter, r *http.Request) { "python": {ext: ".py", cmd: []string{"python3", "-m", "py_compile"}}, "ruby": {ext: ".rb", cmd: []string{"ruby", "-c"}}, "php": {ext: ".php", cmd: []string{"php", "-l"}}, - "perl": {ext: ".pl", cmd: []string{"perl", "-c"}}, "go": {ext: ".go", cmd: nil}, // go проверяем через go/parser } diff --git a/console/ui/index.html b/console/ui/index.html index 20fcbc3..849b393 100644 --- a/console/ui/index.html +++ b/console/ui/index.html @@ -1,10 +1,12 @@ + NUBES Fission Console - +