From df74a69fde6f89b3385e6af5e83f69143d773a97 Mon Sep 17 00:00:00 2001 From: Naeel Date: Sun, 3 May 2026 06:46:27 +0300 Subject: [PATCH] =?UTF-8?q?v1.3.27:=20fix=20Help=20modal=20=E2=80=94=20add?= =?UTF-8?q?=20-L=20flag,=20demo-login=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console/cmd/server/main.go | 17 +- console/deploy/console.yaml | 3 +- console/internal/api/auth.go | 155 +- console/internal/api/auth_handlers.go | 9 - console/internal/api/auth_test.go | 112 -- console/internal/api/function_handlers.go | 19 - console/internal/api/handlers.go | 536 ++++++- console/internal/api/invoke_handlers.go | 48 - console/internal/api/package.go | 11 +- console/internal/api/server.go | 61 +- console/internal/api/utils.go | 25 - console/internal/auth/auth.go | 246 ++++ console/internal/auth/auth_test.go | 111 ++ console/internal/fission/function.go | 258 ---- console/internal/fission/function.go.tmp | 0 console/internal/runtime/archive.go | 22 - console/ui/embed.go | 2 +- console/ui/index.html | 1249 +---------------- console/ui/js/ai.js | 60 +- console/ui/js/api.js | 9 +- console/ui/js/app.js | 425 +----- console/ui/js/auth.js | 40 +- console/ui/js/config.js | 2 +- console/ui/js/functions.js | 92 +- console/ui/js/init.js | 5 +- console/ui/js/modals.js | 77 +- console/ui/js/triggers.js | 2 +- console/ui/js/ui.js | 40 +- .../ui/templates/components/ai-assistant.html | 1 - console/ui/templates/components/navbar.html | 1 - .../ui/templates/components/status-cards.html | 1 - .../ui/templates/modals/create-function.html | 1 - .../ui/templates/modals/edit-function.html | 1 - console/ui/templates/modals/help.html | 1 - console/ui/templates/modals/init-overlay.html | 1 - console/ui/templates/modals/invoke.html | 1 - console/ui/templates/modals/login.html | 32 - 37 files changed, 1179 insertions(+), 2497 deletions(-) delete mode 100644 console/internal/api/auth_handlers.go delete mode 100644 console/internal/api/auth_test.go delete mode 100644 console/internal/api/function_handlers.go delete mode 100644 console/internal/api/invoke_handlers.go delete mode 100644 console/internal/api/utils.go create mode 100644 console/internal/auth/auth.go create mode 100644 console/internal/auth/auth_test.go delete mode 100644 console/internal/fission/function.go delete mode 100644 console/internal/fission/function.go.tmp delete mode 100644 console/internal/runtime/archive.go delete mode 100644 console/ui/templates/components/ai-assistant.html delete mode 100644 console/ui/templates/components/navbar.html delete mode 100644 console/ui/templates/components/status-cards.html delete mode 100644 console/ui/templates/modals/create-function.html delete mode 100644 console/ui/templates/modals/edit-function.html delete mode 100644 console/ui/templates/modals/help.html delete mode 100644 console/ui/templates/modals/init-overlay.html delete mode 100644 console/ui/templates/modals/invoke.html delete mode 100644 console/ui/templates/modals/login.html diff --git a/console/cmd/server/main.go b/console/cmd/server/main.go index 5a3c0ba..63d4b22 100644 --- a/console/cmd/server/main.go +++ b/console/cmd/server/main.go @@ -11,6 +11,7 @@ import ( "time" "fission-console/internal/api" + "fission-console/internal/auth" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" @@ -36,6 +37,19 @@ func main() { log.Fatalf("create dynamic client: %v", err) } + testMode := os.Getenv("FISSION_TEST_MODE") == "true" + + var jwtAuth auth.Authenticator + if testMode { + jwtAuth = &auth.TestAuthenticator{} + } else { + jwtAuth = auth.NewDeckAuthenticator(auth.DefaultDeckAPIs, nil) + } + authenticator := &auth.MultiAuthenticator{ + JWT: jwtAuth, + Demo: &auth.DemoAuthenticator{}, + } + srv := api.NewServer(api.Config{ Dyn: dyn, Namespace: namespace, @@ -45,7 +59,8 @@ func main() { SATokenPath: envDefault("SA_TOKEN_PATH", "/var/run/secrets/kubernetes.io/serviceaccount/token"), AuthUser: envDefault("FISSION_AUTH_USERNAME", ""), AuthPass: envDefault("FISSION_AUTH_PASSWORD", ""), - TestMode: os.Getenv("FISSION_TEST_MODE") == "true", + TestMode: testMode, + Authenticator: authenticator, // --- ai/ask feature --- LLMUrl: envDefault("FISSION_LLM_URL", "https://api.aillm.ru"), LLMKey: os.Getenv("FISSION_LLM_KEY"), diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index b10c3a8..5319b69 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -52,7 +52,8 @@ spec: serviceAccountName: fission-console containers: - name: console - image: naeel/fission-console:v1.3.22 + image: naeel/fission-console:v1.3.27 + imagePullPolicy: Always ports: - containerPort: 8090 env: diff --git a/console/internal/api/auth.go b/console/internal/api/auth.go index 6cb07c6..3c51415 100644 --- a/console/internal/api/auth.go +++ b/console/internal/api/auth.go @@ -2,19 +2,20 @@ package api import ( "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" "fmt" "net/http" "strings" + + "fission-console/internal/auth" ) -// ctxKeyNS — ключ для хранения namespace пользователя в context.Context. -// Использует приватный тип чтобы избежать коллизий с ключами из других пакетов. +// ctxKeyNS — ключ для K8s namespace пользователя в context.Context. type ctxKeyNS struct{} +// ctxKeyIdentity — ключ для UserIdentity пользователя в context.Context. +type ctxKeyIdentity struct{} + +// authTokenFromRequest извлекает Bearer токен из X-Auth-Token или Authorization заголовка. func authTokenFromRequest(r *http.Request) string { token := strings.TrimSpace(r.Header.Get("X-Auth-Token")) if token != "" { @@ -32,61 +33,57 @@ func authTokenFromRequest(r *http.Request) string { } // userNS возвращает namespace пользователя из контекста запроса. -// Устанавливается в authMiddleware после успешной аутентификации. func (s *Server) userNS(r *http.Request) string { if ns, ok := r.Context().Value(ctxKeyNS{}).(string); ok && ns != "" { return ns } - // Fallback: использовать системный namespace (не должно происходить в prod) return s.ns } -// authMiddleware оборачивает handler, добавляя аутентификацию и инициализацию namespace. +// normalizeEnv приводит название стенда к допустимому значению. +func normalizeEnv(env string) string { + switch strings.TrimSpace(strings.ToLower(env)) { + case "prod", "dev", "test": + return strings.TrimSpace(strings.ToLower(env)) + } + return "test" +} + +// authMiddleware оборачивает handler аутентификацией. // -// В testMode (FISSION_TEST_MODE=true): -// - Deck API не вызывается -// - X-Test-Sub или X-Auth-Token задают sub → разные namespace-ы для тестирования -// -// В production: -// - X-Auth-Token валидируется через Deck API -// - Namespace вычисляется из JWT claim "sub" +// В testMode: заголовок X-Test-Sub позволяет задать sub напрямую (без токена). +// Иначе: токен передаётся в s.authenticator.Authenticate — детали скрыты за интерфейсом. func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var ns string - env := strings.TrimSpace(strings.ToLower(r.Header.Get("X-Auth-Env"))) - if _, ok := deckAPIs[env]; !ok { - env = "test" - } + var identity auth.UserIdentity + + env := normalizeEnv(r.Header.Get("X-Auth-Env")) if s.testMode { - sub := strings.TrimSpace(r.Header.Get("X-Test-Sub")) - if sub != "" { - ns = namespaceFromSub(sub) - } else { - token := authTokenFromRequest(r) - resolvedNS, err := s.resolveNamespaceForToken(token, env, true) - if err != nil { - writeJSONError(w, http.StatusUnauthorized, "unauthorized") + if sub := strings.TrimSpace(r.Header.Get("X-Test-Sub")); sub != "" { + identity = auth.UserIdentity{Sub: sub} + ctx := s.contextWithIdentity(r.Context(), identity) + if err := s.nsManager.EnsureUserNS(ctx, auth.NamespaceForSub(sub)); err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err)) return } - ns = resolvedNS - } - } else { - token := authTokenFromRequest(r) - resolvedNS, err := s.resolveNamespaceForToken(token, env, false) - if err != nil { - writeJSONError(w, http.StatusUnauthorized, "unauthorized") + h(w, r.WithContext(ctx)) return } - ns = resolvedNS } - ctx := context.WithValue(r.Context(), ctxKeyNS{}, ns) + token := authTokenFromRequest(r) + var err error + identity, err = s.authenticator.Authenticate(r.Context(), token, env) + if err != nil { + writeJSONError(w, http.StatusUnauthorized, "unauthorized") + return + } - // Гарантируем что namespace + RBAC + quota + netpol существуют. - // EnsureUserNS реализует singleflight + кэш + семафор параллелизма. - if ensureErr := s.nsManager.EnsureUserNS(ctx, ns); ensureErr != nil { - writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", ensureErr)) + ns := auth.NamespaceForSub(identity.Sub) + ctx := s.contextWithIdentity(r.Context(), identity) + if err := s.nsManager.EnsureUserNS(ctx, ns); err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("ensure namespace: %v", err)) return } @@ -94,72 +91,10 @@ func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc { } } -func namespaceFromSub(sub string) string { - h32 := sha256.Sum256([]byte(sub)) - return "fission-" + hex.EncodeToString(h32[:8]) -} - -func (s *Server) resolveNamespaceForToken(token, env string, allowTestSub bool) (string, error) { - token = strings.TrimSpace(token) - if token == "" { - if allowTestSub { - return "", fmt.Errorf("test mode: X-Test-Sub required") - } - return "", fmt.Errorf("unauthorized") - } - - if err := s.validateDeckToken(token, env); err == nil { - ns, nsErr := namespaceFromJWT(token) - if nsErr != nil { - return s.ns, nil - } - return ns, nil - } - - if allowTestSub && strings.Contains(token, "@") { - return namespaceFromSub(token), nil - } - - return "", fmt.Errorf("invalid token") -} - -// namespaceFromJWT декодирует JWT payload (без верификации подписи), -// извлекает claim "sub" и вычисляет namespace: "fission-" + hex(SHA256(sub)[:8]). -// -// Подпись не проверяется — токен уже валидирован через Deck API (validateDeckToken). -// Здесь нам нужен только deterministic namespace name из sub claim. -func namespaceFromJWT(token string) (string, error) { - parts := strings.SplitN(token, ".", 3) - if len(parts) != 3 { - return "", fmt.Errorf("invalid JWT format") - } - payload := parts[1] - - // JWT использует base64url без padding — добавляем если нужно - switch len(payload) % 4 { - case 2: - payload += "==" - case 3: - payload += "=" - } - - // base64url без стандартного padding — пробуем оба варианта - decoded, err := base64.URLEncoding.DecodeString(payload) - if err != nil { - decoded, err = base64.StdEncoding.DecodeString(payload) - if err != nil { - return "", fmt.Errorf("decode JWT payload: %w", err) - } - } - - var claims map[string]any - if err := json.Unmarshal(decoded, &claims); err != nil { - return "", fmt.Errorf("unmarshal JWT claims: %w", err) - } - sub, _ := claims["sub"].(string) - if sub == "" { - return "", fmt.Errorf("JWT missing sub claim") - } - h := sha256.Sum256([]byte(sub)) - return "fission-" + hex.EncodeToString(h[:8]), nil +// contextWithIdentity кладёт UserIdentity и вычисленный namespace в контекст. +func (s *Server) contextWithIdentity(ctx context.Context, identity auth.UserIdentity) context.Context { + ns := auth.NamespaceForSub(identity.Sub) + ctx = context.WithValue(ctx, ctxKeyNS{}, ns) + ctx = context.WithValue(ctx, ctxKeyIdentity{}, identity) + return ctx } diff --git a/console/internal/api/auth_handlers.go b/console/internal/api/auth_handlers.go deleted file mode 100644 index 7524cd9..0000000 --- a/console/internal/api/auth_handlers.go +++ /dev/null @@ -1,9 +0,0 @@ -package api - -import ( - "net/http" -) - -func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { - // ...existing code... -} diff --git a/console/internal/api/auth_test.go b/console/internal/api/auth_test.go deleted file mode 100644 index 5d40e4f..0000000 --- a/console/internal/api/auth_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "testing" - "time" -) - -const validJWTForTests = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXItMTIzIn0.signature" - -func TestResolveNamespaceForTokenRejectsInvalidTokenOutsideTestMode(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/index.cfm/instances" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - w.WriteHeader(http.StatusUnauthorized) - })) - defer server.Close() - - oldDeckAPIs := deckAPIs - deckAPIs = map[string]string{"test": server.URL} - defer func() { deckAPIs = oldDeckAPIs }() - - s := &Server{http: server.Client()} - if _, err := s.resolveNamespaceForToken("user@example.com", "test", false); err == nil { - t.Fatal("expected invalid token to be rejected outside test mode") - } -} - -func TestResolveNamespaceForTokenAllowsValidatedJWTInTestMode(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("Authorization"); got == "" { - t.Fatal("expected Authorization header") - } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"results":[]}`)) - })) - defer server.Close() - - oldDeckAPIs := deckAPIs - deckAPIs = map[string]string{"test": server.URL} - defer func() { deckAPIs = oldDeckAPIs }() - - s := &Server{http: server.Client()} - ns, err := s.resolveNamespaceForToken(validJWTForTests, "test", true) - if err != nil { - t.Fatalf("expected JWT to pass in test mode: %v", err) - } - - expected, err := namespaceFromJWT(validJWTForTests) - if err != nil { - t.Fatalf("namespaceFromJWT: %v", err) - } - if ns != expected { - t.Fatalf("expected namespace %q, got %q", expected, ns) - } -} - -func TestResolveNamespaceForTokenAllowsEmailFallbackOnlyInTestMode(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer server.Close() - - oldDeckAPIs := deckAPIs - deckAPIs = map[string]string{"test": server.URL} - defer func() { deckAPIs = oldDeckAPIs }() - - s := &Server{http: server.Client()} - ns, err := s.resolveNamespaceForToken("user@example.com", "test", true) - if err != nil { - t.Fatalf("expected email fallback in test mode: %v", err) - } - if ns != namespaceFromSub("user@example.com") { - t.Fatalf("unexpected namespace: %q", ns) - } - - _, err = s.resolveNamespaceForToken("user@example.com", "test", false) - if err == nil { - t.Fatal("expected email fallback to be rejected outside test mode") - } -} - -func TestValidateDeckTokenCachesSuccessfulValidation(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"results":[]}`)) - })) - defer server.Close() - - oldDeckAPIs := deckAPIs - deckAPIs = map[string]string{"test": server.URL} - defer func() { deckAPIs = oldDeckAPIs }() - - s := &Server{http: server.Client()} - if err := s.validateDeckToken(validJWTForTests, "test"); err != nil { - t.Fatalf("first validate failed: %v", err) - } - if err := s.validateDeckToken(validJWTForTests, "test"); err != nil { - t.Fatalf("second validate failed: %v", err) - } - if requests != 1 { - t.Fatalf("expected 1 upstream request because of cache, got %d", requests) - } - - if v, ok := s.tokenCache.Load("test:" + validJWTForTests); !ok || time.Now().After(v.(time.Time)) { - t.Fatal("expected token to be cached") - } -} \ No newline at end of file diff --git a/console/internal/api/function_handlers.go b/console/internal/api/function_handlers.go deleted file mode 100644 index fdf7a50..0000000 --- a/console/internal/api/function_handlers.go +++ /dev/null @@ -1,19 +0,0 @@ -package api - -import "net/http" - -func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { - // ...existing code... -} - -func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name string) { - // ...existing code... -} - -func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request, name string) { - // ...existing code... -} - -func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) { - // ...existing code... -} diff --git a/console/internal/api/handlers.go b/console/internal/api/handlers.go index 65d3dfa..57159d1 100644 --- a/console/internal/api/handlers.go +++ b/console/internal/api/handlers.go @@ -3,6 +3,7 @@ package api import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -15,6 +16,7 @@ import ( "strings" "time" + "fission-console/internal/auth" "fission-console/internal/fission" "fission-console/internal/model" "fission-console/internal/runtime" @@ -36,10 +38,189 @@ const maxCodeSize = 1 << 20 // defaultFunctionInvokeTimeout совпадает с дефолтом Fission для spec.functionTimeout. const defaultFunctionInvokeTimeout = 60 * time.Second +func buildDeployArchive(lang, code string) ([]byte, error) { + switch lang { + case "nodejs": + return runtime.BuildJSDeployZip(code) + case "php": + return runtime.BuildScriptZip(code, "main.php") + case "ruby": + return runtime.BuildScriptZip(code, "handler.rb") + default: + return []byte(code), nil + } +} +func (s *Server) resolveInvokeTimeout(fn *unstructured.Unstructured) time.Duration { + if fn != nil { + seconds, found, err := unstructured.NestedInt64(fn.Object, "spec", "functionTimeout") + if err == nil && found && seconds > 0 { + return time.Duration(seconds) * time.Second + } + } + if s.invokeTimeout > 0 { + return s.invokeTimeout + } + return defaultFunctionInvokeTimeout +} +func normalizeFunctionTimeout(seconds int64) int64 { + if seconds <= 0 { + return int64(defaultFunctionInvokeTimeout / time.Second) + } + return seconds +} +func normalizeRoute(route string) string { + route = strings.TrimSpace(route) + if route == "" || route == "/" { + return "/" + } + if !strings.HasPrefix(route, "/") { + return "/" + route + } + return route +} +func routeAllowsMethod(methods []string, method string) bool { + if len(methods) == 0 { + return true + } + for _, candidate := range methods { + if strings.EqualFold(strings.TrimSpace(candidate), method) { + return true + } + } + return false +} + +func appendUniqueMethods(dst []string, src []string) []string { + for _, method := range src { + method = strings.ToUpper(strings.TrimSpace(method)) + if method == "" { + continue + } + seen := false + for _, existing := range dst { + if existing == method { + seen = true + break + } + } + if !seen { + dst = append(dst, method) + } + } + return dst +} + +func shouldForwardRequestBody(method string) bool { + switch method { + case http.MethodGet, http.MethodHead: + return false + default: + return true + } +} + +func copyProxyRequestHeaders(dst, src http.Header) { + for key, values := range src { + switch http.CanonicalHeaderKey(key) { + case "Authorization", "X-Auth-Token", "X-Auth-Env", "Host", "Content-Length": + continue + } + for _, value := range values { + dst.Add(key, value) + } + } +} + +func copyProxyResponseHeaders(dst, src http.Header) { + for key, values := range src { + for _, value := range values { + dst.Add(key, value) + } + } +} + +func doRequestWithContextTimeout(client *http.Client, req *http.Request) (*http.Response, error) { + if client == nil { + return http.DefaultClient.Do(req) + } + invokeClient := *client + // Для invoke реальный лимит должен определяться context timeout функции, + // а не общим HTTP timeout console. + invokeClient.Timeout = 0 + return invokeClient.Do(req) +} + +// handleFunctionsRoot обрабатывает запросы к /console/api/functions без имени функции. +// GET → список всех функций, POST → создать новую. +func (s *Server) handleFunctionsRoot(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.handleList(fission.FunctionGVR)(w, r) + case http.MethodPost: + s.handleCreateFunction(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleFunctionsAction обрабатывает запросы к /console/api/functions/:name[/action]. +// Парсит имя функции и опциональный sub-path ("code", "invoke"). +func (s *Server) handleFunctionsAction(w http.ResponseWriter, r *http.Request) { + // Убираем оба возможных префикса (legacy /api/ и основной /console/api/) + path := strings.TrimPrefix(r.URL.Path, "/api/functions/") + if path == r.URL.Path { + path = strings.TrimPrefix(r.URL.Path, "/console/api/functions/") + } + path = strings.Trim(path, "/") + if path == "" { + http.NotFound(w, r) + return + } + + parts := strings.Split(path, "/") + name := strings.TrimSpace(parts[0]) + if name == "" { + writeJSONError(w, http.StatusBadRequest, "function name is required") + return + } + + if len(parts) == 1 { + // /functions/:name — CRUD операции с конкретной функцией + switch r.Method { + case http.MethodGet: + s.handleGetFunction(w, r, name) + case http.MethodDelete: + s.handleDeleteFunction(w, r, name) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + return + } + + // /functions/:name/code — обновление кода + if len(parts) == 2 && parts[1] == "code" && r.Method == http.MethodPut { + s.handleUpdateFunctionCode(w, r, name) + return + } + + // /functions/:name/invoke — вызов функции + if len(parts) == 2 && parts[1] == "invoke" && r.Method == http.MethodPost { + s.handleInvokeFunction(w, r, name) + return + } + + http.NotFound(w, r) +} + +// handleCreateFunction создаёт новую функцию: Package + Function + HTTPTrigger. +// +// Порядок создания: Package → Function → HTTPTrigger. +// При ошибке на любом шаге откатываем уже созданные объекты (best-effort). +// TTL парсится ДО создания объектов — невалидный TTL не оставляет мусор. func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { ns := s.userNS(r) @@ -121,19 +302,140 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { return } - result, err := fission.CreateFunction(ctx, s.dyn, ns, req) - if err != nil { - status := http.StatusBadGateway - if apierrors.IsAlreadyExists(err) { - status = http.StatusConflict - } else if apierrors.IsInvalid(err) { - status = http.StatusBadRequest + pkgName := req.Name + "-pkg" + triggerName := req.Name + "-route" + + methodValues := make([]any, 0, len(req.Methods)) + for _, method := range req.Methods { + methodValues = append(methodValues, method) + } + + // Строим Package spec в зависимости от языка: + // - Go: source package → builder job компилирует в .so плагин + // - Node.js: deployment zip с ESM wrapper (package.json + main.js) + // - Остальные: deployment literal с кодом напрямую + var pkgSpec map[string]any + if req.Language == "go" { + srcZip, err := runtime.BuildGoSourceZip(req.Code) + if err != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("build go source archive: %v", err)) + return } - writeJSONError(w, status, err.Error()) + literal := base64.StdEncoding.EncodeToString(srcZip) + pkgSpec = map[string]any{ + "source": map[string]any{ + "type": "literal", + "literal": literal, + }, + "deployment": map[string]any{}, + "environment": map[string]any{"name": req.Environment, "namespace": ns}, + "buildcommand": "build", + } + } else { + deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code) + if archiveErr != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr)) + return + } + pkgSpec = map[string]any{ + "deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)}, + "environment": map[string]any{"name": req.Environment, "namespace": ns}, + "source": map[string]any{}, + } + } + + pkg := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Package", + "metadata": map[string]any{"name": pkgName, "namespace": ns}, + "spec": pkgSpec, + }} + + // Парсим TTL ДО создания K8s ресурсов — невалидный TTL не оставляет мусор + fnAnnotations := map[string]any{ + "fission-console/language": req.Language, + } + now := time.Now().UTC() + fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339) + fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339) + if req.TTL != "" { + expiresAt, ttlErr := parseTTL(req.TTL) + if ttlErr != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid ttl %q: %v", req.TTL, ttlErr)) + return + } + fnAnnotations["fission-console/expires-at"] = expiresAt.UTC().Format(time.RFC3339) + } + + if _, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil { + if apierrors.IsAlreadyExists(err) { + writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name)) + return + } + if apierrors.IsInvalid(err) { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid function spec: %v", err)) + return + } + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create package: %v", err)) return } - writeAnyJSON(w, http.StatusCreated, result) + fn := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Function", + "metadata": map[string]any{"name": req.Name, "namespace": ns, "annotations": fnAnnotations}, + "spec": map[string]any{ + "environment": map[string]any{"name": req.Environment, "namespace": ns}, + "functionTimeout": req.Timeout, + "InvokeStrategy": map[string]any{ + "ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"}, + "StrategyType": "execution", + }, + "package": map[string]any{ + "packageref": map[string]any{"name": pkgName, "namespace": ns}, + "functionName": req.Entrypoint, + }, + }, + }} + + if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil { + // Откатываем Package если Function не создалась + _ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{}) + if apierrors.IsAlreadyExists(err) { + writeJSONError(w, http.StatusConflict, fmt.Sprintf("function %q already exists", req.Name)) + return + } + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create function: %v", err)) + return + } + + httpTrigger := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "HTTPTrigger", + "metadata": map[string]any{"name": triggerName, "namespace": ns}, + "spec": map[string]any{ + "relativeurl": req.Route, + "methods": methodValues, + "createingress": true, + "functionref": map[string]any{"type": "name", "name": req.Name}, + }, + }} + + if _, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Create(ctx, httpTrigger, metav1.CreateOptions{}); err != nil { + // Откатываем Function и Package + _ = s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, req.Name, metav1.DeleteOptions{}) + _ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{}) + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create httptrigger: %v", err)) + return + } + + writeAnyJSON(w, http.StatusCreated, map[string]any{ + "name": req.Name, + "package": pkgName, + "httptrigger": triggerName, + "route": req.Route, + "expires_at": fnAnnotations["fission-console/expires-at"], + }) } // handleGetFunction возвращает детали функции: код, environment, route, methods. @@ -165,7 +467,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name if packageName != "" { pkg, pkgErr := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, packageName, metav1.GetOptions{}) if pkgErr == nil { - code = extractPackageSourceCode(ctx, s.http, pkg) + code = extractPackageSourceCode(ctx, s, pkg) } } @@ -201,6 +503,10 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name }) } +// handleUpdateFunctionCode обновляет код уже существующей функции. +// Создаёт НОВЫЙ Package (вместо обновления старого) чтобы executor сбросил кэш: +// executor кэширует function service по functionUid и не видит изменений в том же Package. +// Новое имя пакета гарантирует cache miss в executor. func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request, name string) { var req model.UpdateCodeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -212,25 +518,109 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request writeJSONError(w, http.StatusBadRequest, "code is required") return } - if req.Timeout <= 0 { - req.Timeout = int64(defaultFunctionInvokeTimeout / time.Second) - } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() ns := s.userNS(r) - result, err := fission.UpdateFunctionCode(ctx, s.dyn, ns, name, req) + 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, err.Error()) + writeJSONError(w, status, fmt.Sprintf("get function %q: %v", name, err)) return } - writeAnyJSON(w, http.StatusOK, result) + oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name") + + // Определяем язык из аннотации — нужен для правильной упаковки + lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language") + deployBytes, archiveErr := buildDeployArchive(lang, req.Code) + if archiveErr != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr)) + return + } + + // Создаём новый Package с уникальным именем. + // Это единственный способ сбросить кэш executor: он кэширует по functionUid и + // не замечает изменений в существующем Package. + envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name") + createdAt := func() time.Time { + ann := fn.GetAnnotations() + if ann != nil { + if v := strings.TrimSpace(ann[functionCreatedAtAnnotation]); v != "" { + if ts, err := parseRFC3339(v); err == nil { + return ts.UTC() + } + } + } + if ts := fn.GetCreationTimestamp(); !ts.IsZero() { + return ts.UTC() + } + return time.Time{} + }() + now := time.Now().UTC() + newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36) + newPkg := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "fission.io/v1", + "kind": "Package", + "metadata": map[string]any{"name": newPkgName, "namespace": ns}, + "spec": map[string]any{ + "deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)}, + "environment": map[string]any{"name": envName, "namespace": ns}, + "source": map[string]any{}, + }, + }} + + createdPkg, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{}) + if err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create new package: %v", err)) + return + } + + // Обновляем Function на новый Package + if err := unstructured.SetNestedField(fn.Object, normalizeFunctionTimeout(req.Timeout), "spec", "functionTimeout"); err != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function timeout: %v", err)) + _ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{}) + return + } + ensureFunctionTimestamps(fn, now) + if createdAt.IsZero() { + createdAt = now + } + fnAnnotations := fn.GetAnnotations() + if fnAnnotations == nil { + fnAnnotations = map[string]string{} + } + fnAnnotations[functionCreatedAtAnnotation] = createdAt.UTC().Format(time.RFC3339) + fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339) + fn.SetAnnotations(fnAnnotations) + if err := unstructured.SetNestedField(fn.Object, map[string]any{ + "name": newPkgName, + "namespace": ns, + "resourceversion": createdPkg.GetResourceVersion(), + }, "spec", "package", "packageref"); err != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("set function packageref: %v", err)) + _ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{}) + return + } + if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("update function %q: %v", name, err)) + _ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, newPkgName, metav1.DeleteOptions{}) + return + } + + // Удаляем старый Package (best effort) + if oldPkgName != "" && oldPkgName != newPkgName { + _ = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, oldPkgName, metav1.DeleteOptions{}) + } + + writeAnyJSON(w, http.StatusOK, map[string]any{ + "updated": true, + "package": newPkgName, + }) } // handleInvokeFunction вызывает функцию через Fission router. @@ -562,26 +952,108 @@ func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(w, resp.Body) } +// handleDeleteFunction удаляет функцию и связанные объекты: HTTPTrigger, TimeTrigger, Package. +// После удаления вызывает CleanupEnvironmentIfUnused — убирает environment если язык больше не используется. func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, name string) { ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() ns := s.userNS(r) - if err := fission.DeleteFunction(ctx, s.dyn, ns, name); err != nil { - status := http.StatusBadGateway + // Получаем Function чтобы знать pkgName и envName для cleanup + var pkgName, envName string + fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { if apierrors.IsNotFound(err) { - status = http.StatusNotFound + writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", name)) + return } - writeJSONError(w, status, err.Error()) + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", name, err)) + return + } + pkgName, _, _ = unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name") + envName, _, _ = unstructured.NestedString(fn.Object, "spec", "environment", "name") + + // Удаляем связанные HTTPTrigger-ы + triggers, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{}) + if err == nil { + for _, trig := range triggers.Items { + refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name") + if refName == name { + _ = s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{}) + } + } + } + + // Удаляем связанные TimeTrigger-ы + if triggers, err := s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).List(ctx, metav1.ListOptions{}); err == nil { + for _, trig := range triggers.Items { + refName, _, _ := unstructured.NestedString(trig.Object, "spec", "functionref", "name") + if refName == name { + _ = s.dyn.Resource(fission.TimeTrigGVR).Namespace(ns).Delete(ctx, trig.GetName(), metav1.DeleteOptions{}) + } + } + } + + if err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete function %q: %v", name, err)) return } - writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name}) + if pkgName != "" { + if err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("delete package %q: %v", pkgName, err)) + return + } + } + + // Убираем environment pool pods если язык больше не используется (best-effort) + if envName != "" { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cleanupCancel() + fission.CleanupEnvironmentIfUnused(cleanupCtx, s.dyn, ns, envName) + } + + // (reconciler NS удалён — за FISSION_RESOURCE_NAMESPACES теперь отвечает Layer 1 NSWatcher) + + writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name, "package": pkgName}) } // handleAuth обрабатывает POST /console/api/auth. -// Валидирует токен, создаёт namespace, возвращает namespace пользователя. +// Валидирует токен через authenticator, создаёт namespace, возвращает namespace + email. +func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var body struct { + Token string `json:"token"` + Env string `json:"env"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Token) == "" { + writeJSONError(w, http.StatusBadRequest, "token required") + return + } + + env := normalizeEnv(body.Env) + + identity, err := s.authenticator.Authenticate(r.Context(), body.Token, env) + if err != nil { + writeJSONError(w, http.StatusUnauthorized, "invalid token") + return + } + + ns := auth.NamespaceForSub(identity.Sub) + + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + if ensureErr := s.nsManager.EnsureUserNS(ctx, ns); ensureErr != nil { + log.Printf("handleAuth: ensureUserNS %s: %v", ns, ensureErr) + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email}) +} // parseTTL парсит строку TTL и возвращает время истечения. // Поддерживаемые форматы: Go duration (1h, 30m, 24h) и дни (1d, 7d, 30d). @@ -606,4 +1078,22 @@ func parseTTL(ttl string) (time.Time, error) { // normalizeMethods приводит список HTTP методов к верхнему регистру, убирает дубли. // Если список пустой или все элементы пустые — возвращает ["GET"]. - +func normalizeMethods(in []string) []string { + if len(in) == 0 { + return []string{"GET"} + } + out := make([]string, 0, len(in)) + seen := map[string]bool{} + for _, method := range in { + m := strings.ToUpper(strings.TrimSpace(method)) + if m == "" || seen[m] { + continue + } + seen[m] = true + out = append(out, m) + } + if len(out) == 0 { + return []string{"GET"} + } + return out +} diff --git a/console/internal/api/invoke_handlers.go b/console/internal/api/invoke_handlers.go deleted file mode 100644 index dc4a436..0000000 --- a/console/internal/api/invoke_handlers.go +++ /dev/null @@ -1,48 +0,0 @@ -package api - -import ( - "net/http" - "time" - - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, name string) { - // ...existing code... -} - -func (s *Server) handleFissionFunctionGateway(w http.ResponseWriter, r *http.Request) { - // ...existing code... -} - -func (s *Server) invokeInternalFunction(w http.ResponseWriter, r *http.Request, namespace, functionName, extraPath string) { - // ...existing code... -} - -func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) { - // ...existing code... -} - -func buildInternalInvokeURL(routerURL, namespace, functionName string) string { - // ...existing code... -} - -func resolveInvokeTimeout(fn *unstructured.Unstructured) time.Duration { - // ...existing code... -} - -func shouldForwardRequestBody(method string) bool { - // ...existing code... -} - -func copyProxyRequestHeaders(dst, src http.Header) { - // ...existing code... -} - -func copyProxyResponseHeaders(dst, src http.Header) { - // ...existing code... -} - -func doRequestWithContextTimeout(client *http.Client, req *http.Request) (*http.Response, error) { - // ...existing code... -} diff --git a/console/internal/api/package.go b/console/internal/api/package.go index 9ae0a85..6fa044d 100644 --- a/console/internal/api/package.go +++ b/console/internal/api/package.go @@ -24,7 +24,7 @@ import ( // // Декодирование: если данные — валидный UTF-8, возвращаем как есть. // Если это zip — ищем известные файлы (main.py, handler.rb и т.д.). -func extractPackageSourceCode(ctx context.Context, httpClient *http.Client, pkg *unstructured.Unstructured) string { +func extractPackageSourceCode(ctx context.Context, s *Server, pkg *unstructured.Unstructured) string { literalPaths := [][]string{ {"spec", "source", "literal"}, {"spec", "deployment", "literal"}, @@ -49,7 +49,7 @@ func extractPackageSourceCode(ctx context.Context, httpClient *http.Client, pkg if !found || strings.TrimSpace(urlValue) == "" { continue } - archiveBytes, err := fetchPackageArchive(ctx, httpClient, urlValue) + archiveBytes, err := fetchPackageArchive(ctx, s, urlValue) if err != nil { continue } @@ -62,15 +62,12 @@ func extractPackageSourceCode(ctx context.Context, httpClient *http.Client, pkg } // fetchPackageArchive скачивает архив функции по URL из Fission storage. -func fetchPackageArchive(ctx context.Context, httpClient *http.Client, archiveURL string) ([]byte, error) { +func fetchPackageArchive(ctx context.Context, s *Server, archiveURL string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL, nil) if err != nil { return nil, err } - if httpClient == nil { - httpClient = http.DefaultClient - } - resp, err := httpClient.Do(req) + resp, err := s.http.Do(req) if err != nil { return nil, err } diff --git a/console/internal/api/server.go b/console/internal/api/server.go index 16c2a46..c05e8f8 100644 --- a/console/internal/api/server.go +++ b/console/internal/api/server.go @@ -5,13 +5,13 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "os" "strings" "sync" "time" + "fission-console/internal/auth" "fission-console/internal/cloud" "fission-console/internal/fission" "fission-console/ui" @@ -22,20 +22,12 @@ import ( "k8s.io/client-go/dynamic" ) -// deckAPIs — карта окружений Deck API. -// Ключ используется в X-Auth-Env заголовке для выбора нужного сервера. -var deckAPIs = map[string]string{ - "prod": "https://deck-api.ngcloud.ru/api/v1", - "dev": "https://deck-api-dev.ngcloud.ru/api/v1", - "test": "https://deck-api-test.ngcloud.ru/api/v1", -} - // defaultSATokenPath — путь к service account токену внутри pod-а. // Используется для авторизации запросов от console к Fission router. const defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" // Server — основная структура HTTP сервера. -// Содержит все зависимости: kubernetes client, конфиги, кэши токенов. +// Содержит все зависимости: kubernetes client, конфиги, кэши. type Server struct { dyn dynamic.Interface ns string // системный namespace (fallback, обычно "fission") @@ -44,7 +36,7 @@ type Server struct { saTokenPath string invokeTimeout time.Duration - testMode bool // FISSION_TEST_MODE=true — пропускает deck auth + testMode bool // FISSION_TEST_MODE=true — разрешает X-Test-Sub shortcut authUser string authPass string @@ -59,9 +51,8 @@ type Server struct { cachedJWT string tokenExpAt time.Time - // tokenCache кэширует результаты валидации Deck токенов. - // Ключ: "env:token", значение: time.Time — когда кэш истекает. - tokenCache sync.Map + // authenticator — слой аутентификации. Сервер не знает деталей реализации. + authenticator auth.Authenticator // nsManager управляет жизненным циклом пользовательских namespace-ов. nsManager *cloud.NSManager @@ -78,6 +69,7 @@ type Config struct { AuthUser string AuthPass string TestMode bool + Authenticator auth.Authenticator // слой аутентификации LLMUrl string LLMKey string } @@ -94,6 +86,7 @@ func NewServer(cfg Config) *Server { authUser: cfg.AuthUser, authPass: cfg.AuthPass, testMode: cfg.TestMode, + authenticator: cfg.Authenticator, llmURL: cfg.LLMUrl, llmKey: cfg.LLMKey, nsManager: cloud.NewNSManager(cfg.Dyn), @@ -274,43 +267,3 @@ func (s *Server) getRouterToken() string { s.tokenExpAt = time.Now().Add(100 * time.Second) return s.cachedJWT } - -// validateDeckToken проверяет токен через Deck API с кэшированием результата на 5 минут. -func (s *Server) validateDeckToken(token, env string) error { - cacheKey := env + ":" + token - if v, ok := s.tokenCache.Load(cacheKey); ok { - if time.Now().Before(v.(time.Time)) { - return nil // кэш актуален — токен валиден - } - s.tokenCache.Delete(cacheKey) - } - - apiBase, ok := deckAPIs[env] - if !ok { - return fmt.Errorf("unknown deck env: %s", env) - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+"/index.cfm/instances", nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+token) - - resp, err := s.http.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - _, _ = io.ReadAll(resp.Body) - - if resp.StatusCode == http.StatusUnauthorized { - return fmt.Errorf("invalid token") - } - - // Кэшируем успешный результат - s.tokenCache.Store(cacheKey, time.Now().Add(5*time.Minute)) - return nil -} diff --git a/console/internal/api/utils.go b/console/internal/api/utils.go deleted file mode 100644 index 60c4680..0000000 --- a/console/internal/api/utils.go +++ /dev/null @@ -1,25 +0,0 @@ -package api - -import ( - "strings" -) - -func normalizeFunctionTimeout(seconds int64) int64 { - // ...existing code... -} - -func normalizeRoute(route string) string { - // ...existing code... -} - -func normalizeMethods(in []string) []string { - // ...existing code... -} - -func routeAllowsMethod(methods []string, method string) bool { - // ...existing code... -} - -func appendUniqueMethods(dst []string, src []string) []string { - // ...existing code... -} diff --git a/console/internal/auth/auth.go b/console/internal/auth/auth.go new file mode 100644 index 0000000..1b5a606 --- /dev/null +++ b/console/internal/auth/auth.go @@ -0,0 +1,246 @@ +// Package auth реализует слой аутентификации консоли. +// +// Сервер получает UserIdentity (Sub + Email) и больше не знает +// ни про Deck API, ни про JWT подписи, ни про тест/прод режимы. +package auth + +import ( + "context" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// UserIdentity — идентификатор пользователя, полученный после аутентификации. +type UserIdentity struct { + Sub string // ID пользователя (claim "sub" из JWT) — обязательно + Email string // email — опционально, для отображения в UI +} + +// NamespaceForSub вычисляет детерминированный K8s namespace из Sub: +// "fission-" + hex(SHA256(sub)[:8]) +func NamespaceForSub(sub string) string { + h := sha256.Sum256([]byte(sub)) + return "fission-" + hex.EncodeToString(h[:8]) +} + +// Authenticator — интерфейс аутентификации. Единственная точка входа. +// Сервер не знает как именно проверяется токен — только вызывает Authenticate. +type Authenticator interface { + Authenticate(ctx context.Context, token, env string) (UserIdentity, error) +} + +// --- DeckAuthenticator --- + +// DeckAPIs — карта URL Deck API по стенду. +type DeckAPIs map[string]string + +// DefaultDeckAPIs — стандартные URL для всех стендов облака. +var DefaultDeckAPIs = DeckAPIs{ + "prod": "https://deck-api.ngcloud.ru/api/v1", + "dev": "https://deck-api-dev.ngcloud.ru/api/v1", + "test": "https://deck-api-test.ngcloud.ru/api/v1", +} + +// DeckAuthenticator — production реализация. +// Валидирует токен через Deck API, декодирует sub + email из JWT payload. +type DeckAuthenticator struct { + apis DeckAPIs + http *http.Client + tokenCache sync.Map // key: "env:token", value: time.Time (expiry) +} + +// NewDeckAuthenticator создаёт DeckAuthenticator. +// Если httpClient == nil — используется клиент с таймаутом 5s. +func NewDeckAuthenticator(apis DeckAPIs, httpClient *http.Client) *DeckAuthenticator { + if httpClient == nil { + httpClient = &http.Client{Timeout: 5 * time.Second} + } + return &DeckAuthenticator{apis: apis, http: httpClient} +} + +// Authenticate проверяет токен через Deck API и возвращает UserIdentity. +func (d *DeckAuthenticator) Authenticate(ctx context.Context, token, env string) (UserIdentity, error) { + token = strings.TrimSpace(token) + if token == "" { + return UserIdentity{}, fmt.Errorf("token required") + } + if err := d.validateToken(ctx, token, env); err != nil { + return UserIdentity{}, err + } + return identityFromJWT(token) +} + +func (d *DeckAuthenticator) validateToken(ctx context.Context, token, env string) error { + cacheKey := env + ":" + token + if v, ok := d.tokenCache.Load(cacheKey); ok { + if time.Now().Before(v.(time.Time)) { + return nil + } + d.tokenCache.Delete(cacheKey) + } + + apiBase, ok := d.apis[env] + if !ok { + return fmt.Errorf("unknown env: %s", env) + } + + reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, apiBase+"/index.cfm/instances", nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := d.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("invalid token") + } + + d.tokenCache.Store(cacheKey, time.Now().Add(5*time.Minute)) + return nil +} + +// --- TestAuthenticator --- + +// TestAuthenticator — реализация для тест-режима. Не ходит в Deck API. +// Принимает JWT с claim "sub" или строку с "@" как email-sub. +type TestAuthenticator struct{} + +// Authenticate в тест-режиме: декодирует JWT если возможно, +// иначе использует email-строку как sub. +func (t *TestAuthenticator) Authenticate(_ context.Context, token, _ string) (UserIdentity, error) { + token = strings.TrimSpace(token) + if token == "" { + return UserIdentity{}, fmt.Errorf("token required") + } + if identity, err := identityFromJWT(token); err == nil { + return identity, nil + } + if strings.Contains(token, "@") { + return UserIdentity{Sub: token, Email: token}, nil + } + return UserIdentity{}, fmt.Errorf("invalid token") +} + +// --- helpers --- + +// identityFromJWT декодирует JWT payload (без верификации подписи). +// Подпись не проверяется — токен уже валидирован через Deck API. +func identityFromJWT(token string) (UserIdentity, error) { + parts := strings.SplitN(token, ".", 3) + if len(parts) != 3 { + return UserIdentity{}, fmt.Errorf("invalid JWT format") + } + payload := parts[1] + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + decoded, err := base64.URLEncoding.DecodeString(payload) + if err != nil { + decoded, err = base64.StdEncoding.DecodeString(payload) + if err != nil { + return UserIdentity{}, fmt.Errorf("decode JWT payload: %w", err) + } + } + var claims map[string]any + if err := json.Unmarshal(decoded, &claims); err != nil { + return UserIdentity{}, fmt.Errorf("unmarshal claims: %w", err) + } + sub, _ := claims["sub"].(string) + if sub == "" { + return UserIdentity{}, fmt.Errorf("JWT missing sub claim") + } + email, _ := claims["email"].(string) + return UserIdentity{Sub: sub, Email: email}, nil +} + +// isJWT возвращает true если строка похожа на JWT (три непустые части через точку). +func isJWT(s string) bool { + parts := strings.SplitN(s, ".", 4) + return len(parts) == 3 && parts[0] != "" && parts[1] != "" && parts[2] != "" +} + +// uuidV5 генерирует детерминированный UUID v5 (RFC 4122) из строки. +// Namespace: произвольный фиксированный UUID для demo-логинов. +func uuidV5(name string) string { + // Фиксированный namespace UUID для demo-логинов + ns := []byte{0xf4, 0x7a, 0xc1, 0x0b, 0x58, 0xcc, 0x43, 0x72, 0xa5, 0x67, 0x0e, 0x02, 0xb2, 0xc3, 0xd4, 0x79} + h := sha1.New() + _, _ = h.Write(ns) + _, _ = h.Write([]byte(name)) + sum := h.Sum(nil) + sum[6] = (sum[6] & 0x0f) | 0x50 // version 5 + sum[8] = (sum[8] & 0x3f) | 0x80 // RFC 4122 variant + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16]) +} + +// demoEmail формирует маскированный email для демо-логина. +// Пример: "mylogin" → "demo-my***in" +func demoEmail(login string) string { + r := []rune(login) + n := len(r) + if n <= 4 { + return "demo-" + string(r) + } + return "demo-" + string(r[:2]) + strings.Repeat("*", n-4) + string(r[n-2:]) +} + +// --- DemoAuthenticator --- + +// DemoAuthenticator — аутентификация по произвольному логину (≥6 символов). +// Не обращается ни к каким внешним сервисам. +// Sub = UUID v5 от логина (детерминированный), Email = маскированный логин. +type DemoAuthenticator struct{} + +// Authenticate принимает логин ≥6 символов и возвращает UserIdentity. +func (d *DemoAuthenticator) Authenticate(_ context.Context, login, _ string) (UserIdentity, error) { + login = strings.TrimSpace(login) + if len([]rune(login)) < 6 { + return UserIdentity{}, fmt.Errorf("login must be at least 6 characters") + } + return UserIdentity{Sub: uuidV5(login), Email: demoEmail(login)}, nil +} + +// --- MultiAuthenticator --- + +// MultiAuthenticator роутит аутентификацию: +// - JWT (три части через точку) → JWT authenticator (DeckAuthenticator или TestAuthenticator) +// - Иначе → DemoAuthenticator +// Оба пути равноправны и возвращают одинаковый UserIdentity. +type MultiAuthenticator struct { + JWT Authenticator + Demo *DemoAuthenticator +} + +// Authenticate определяет тип входных данных и делегирует нужному authenticator. +func (m *MultiAuthenticator) Authenticate(ctx context.Context, token, env string) (UserIdentity, error) { + token = strings.TrimSpace(token) + if token == "" { + return UserIdentity{}, fmt.Errorf("token or login required") + } + if isJWT(token) { + return m.JWT.Authenticate(ctx, token, env) + } + return m.Demo.Authenticate(ctx, token, env) +} diff --git a/console/internal/auth/auth_test.go b/console/internal/auth/auth_test.go new file mode 100644 index 0000000..5d91959 --- /dev/null +++ b/console/internal/auth/auth_test.go @@ -0,0 +1,111 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +const validJWTForTests = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXItMTIzIn0.signature" + +func TestDeckAuthenticatorRejectsInvalidToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + a := NewDeckAuthenticator(DeckAPIs{"test": server.URL}, server.Client()) + if _, err := a.Authenticate(context.Background(), "bad-token", "test"); err == nil { + t.Fatal("expected error for invalid token") + } +} + +func TestDeckAuthenticatorAcceptsValidJWT(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + t.Fatal("expected Authorization header") + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + a := NewDeckAuthenticator(DeckAPIs{"test": server.URL}, server.Client()) + identity, err := a.Authenticate(context.Background(), validJWTForTests, "test") + if err != nil { + t.Fatalf("expected success: %v", err) + } + if identity.Sub != "test-user-123" { + t.Fatalf("unexpected sub: %q", identity.Sub) + } + if NamespaceForSub(identity.Sub) != NamespaceForSub("test-user-123") { + t.Fatalf("unexpected namespace: %q", NamespaceForSub(identity.Sub)) + } +} + +func TestDeckAuthenticatorCachesValidToken(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + a := NewDeckAuthenticator(DeckAPIs{"test": server.URL}, server.Client()) + if _, err := a.Authenticate(context.Background(), validJWTForTests, "test"); err != nil { + t.Fatalf("first auth: %v", err) + } + if _, err := a.Authenticate(context.Background(), validJWTForTests, "test"); err != nil { + t.Fatalf("second auth: %v", err) + } + if requests != 1 { + t.Fatalf("expected 1 upstream request (cache), got %d", requests) + } + // проверяем что кэш выставлен + if v, ok := a.tokenCache.Load("test:" + validJWTForTests); !ok || time.Now().After(v.(time.Time)) { + t.Fatal("expected token to be cached with future expiry") + } +} + +func TestTestAuthenticatorAcceptsJWT(t *testing.T) { + a := &TestAuthenticator{} + identity, err := a.Authenticate(context.Background(), validJWTForTests, "test") + if err != nil { + t.Fatalf("expected success: %v", err) + } + if identity.Sub != "test-user-123" { + t.Fatalf("unexpected sub: %q", identity.Sub) + } +} + +func TestTestAuthenticatorAcceptsEmail(t *testing.T) { + a := &TestAuthenticator{} + identity, err := a.Authenticate(context.Background(), "user@example.com", "test") + if err != nil { + t.Fatalf("expected success: %v", err) + } + if identity.Sub != "user@example.com" { + t.Fatalf("unexpected sub: %q", identity.Sub) + } +} + +func TestTestAuthenticatorRejectsInvalidToken(t *testing.T) { + a := &TestAuthenticator{} + if _, err := a.Authenticate(context.Background(), "notajwt", "test"); err == nil { + t.Fatal("expected error for non-JWT non-email token") + } +} + +func TestNamespaceForSub(t *testing.T) { + ns := NamespaceForSub("test-user-123") + if len(ns) == 0 { + t.Fatal("empty namespace") + } + if ns != NamespaceForSub("test-user-123") { + t.Fatal("namespace not deterministic") + } + if NamespaceForSub("a") == NamespaceForSub("b") { + t.Fatal("different subs should produce different namespaces") + } +} diff --git a/console/internal/fission/function.go b/console/internal/fission/function.go deleted file mode 100644 index 2b49c35..0000000 --- a/console/internal/fission/function.go +++ /dev/null @@ -1,258 +0,0 @@ -package fission - -import ( - "context" - "encoding/base64" - "fmt" - "strconv" - "time" - - "fission-console/internal/model" - "fission-console/internal/runtime" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/client-go/dynamic" -) - -const ( - functionCreatedAtAnnotation = "fission-console/createdAt" - functionUpdatedAtAnnotation = "fission-console/updatedAt" -) - -// CreateFunctionResult holds the result of a CreateFunction call. -type CreateFunctionResult struct { - Name string `json:"name"` - Package string `json:"package"` - HTTPTrigger string `json:"httptrigger"` - Route string `json:"route"` - ExpiresAt string `json:"expires_at,omitempty"` -} - -// UpdateFunctionResult holds the result of an UpdateFunctionCode call. -type UpdateFunctionResult struct { - Updated bool `json:"updated"` - Package string `json:"package"` -} - -// CreateFunction orchestrates the creation of a complete Fission function set: Package, Function, and HTTPTrigger. -func CreateFunction(ctx context.Context, dyn dynamic.Interface, ns string, req model.CreateFunctionRequest) (*CreateFunctionResult, error) { - pkgName := req.Name + "-pkg" - triggerName := req.Name + "-route" - - methodValues := make([]any, 0, len(req.Methods)) - for _, method := range req.Methods { - methodValues = append(methodValues, method) - } - - var pkgSpec map[string]any - if req.Language == "go" { - srcZip, err := runtime.BuildGoSourceZip(req.Code) - if err != nil { - return nil, fmt.Errorf("build go source archive: %w", err) - } - literal := base64.StdEncoding.EncodeToString(srcZip) - pkgSpec = map[string]any{ - "source": map[string]any{ - "type": "literal", - "literal": literal, - }, - "deployment": map[string]any{}, - "environment": map[string]any{"name": req.Environment, "namespace": ns}, - "buildcommand": "build", - } - } else { - deployBytes, archiveErr := runtime.BuildDeployArchive(req.Language, req.Code) - if archiveErr != nil { - return nil, fmt.Errorf("build %s archive: %w", req.Language, archiveErr) - } - pkgSpec = map[string]any{ - "deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)}, - "environment": map[string]any{"name": req.Environment, "namespace": ns}, - "source": map[string]any{}, - } - } - - pkg := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "fission.io/v1", - "kind": "Package", - "metadata": map[string]any{"name": pkgName, "namespace": ns}, - "spec": pkgSpec, - }} - - fnAnnotations := map[string]any{ - "fission-console/language": req.Language, - } - now := time.Now().UTC() - fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339) - fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339) - var expiresAtStr string - if req.TTL != "" { - expiresAt, ttlErr := parseTTL(req.TTL) - if ttlErr != nil { - return nil, fmt.Errorf("invalid ttl %q: %w", req.TTL, ttlErr) - } - expiresAtStr = expiresAt.UTC().Format(time.RFC3339) - fnAnnotations["fission-console/expires-at"] = expiresAtStr - } - - if _, err := dyn.Resource(PackageGVR).Namespace(ns).Create(ctx, pkg, metav1.CreateOptions{}); err != nil { - return nil, fmt.Errorf("create package: %w", err) - } - - fn := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "fission.io/v1", - "kind": "Function", - "metadata": map[string]any{"name": req.Name, "namespace": ns, "annotations": fnAnnotations}, - "spec": map[string]any{ - "environment": map[string]any{"name": req.Environment, "namespace": ns}, - "functionTimeout": req.Timeout, - "InvokeStrategy": map[string]any{ - "ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"}, - "StrategyType": "execution", - }, - "package": map[string]any{ - "packageref": map[string]any{"name": pkgName, "namespace": ns}, - "functionName": req.Entrypoint, - }, - }, - }} - - if _, err := dyn.Resource(FunctionGVR).Namespace(ns).Create(ctx, fn, metav1.CreateOptions{}); err != nil { - _ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), pkgName, metav1.DeleteOptions{}) - return nil, fmt.Errorf("create function: %w", err) - } - - httpTrigger := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "fission.io/v1", - "kind": "HTTPTrigger", - "metadata": map[string]any{"name": triggerName, "namespace": ns}, - "spec": map[string]any{ - "relativeurl": req.Route, - "methods": methodValues, - "createingress": true, - "functionref": map[string]any{"type": "name", "name": req.Name}, - }, - }} - - if _, err := dyn.Resource(HTTPTrigGVR).Namespace(ns).Create(ctx, httpTrigger, metav1.CreateOptions{}); err != nil { - _ = dyn.Resource(FunctionGVR).Namespace(ns).Delete(context.Background(), req.Name, metav1.DeleteOptions{}) - _ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), pkgName, metav1.DeleteOptions{}) - return nil, fmt.Errorf("create httptrigger: %w", err) - } - - return &CreateFunctionResult{ - Name: req.Name, - Package: pkgName, - HTTPTrigger: triggerName, - Route: req.Route, - ExpiresAt: expiresAtStr, - }, nil -} - -// UpdateFunctionCode orchestrates updating the code of an existing function. -func UpdateFunctionCode(ctx context.Context, dyn dynamic.Interface, ns, name string, req model.UpdateCodeRequest) (*UpdateFunctionResult, error) { - fn, err := dyn.Resource(FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return nil, fmt.Errorf("get function %q: %w", name, err) - } - - oldPkgName, _, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name") - lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language") - - deployBytes, archiveErr := runtime.BuildDeployArchive(lang, req.Code) - if archiveErr != nil { - return nil, fmt.Errorf("build %s archive: %w", lang, archiveErr) - } - - envName, _, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name") - newPkgName := name + "-pkg-" + strconv.FormatInt(time.Now().UnixMilli(), 36) - newPkg := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "fission.io/v1", - "kind": "Package", - "metadata": map[string]any{"name": newPkgName, "namespace": ns}, - "spec": map[string]any{ - "deployment": map[string]any{"type": "literal", "literal": base64.StdEncoding.EncodeToString(deployBytes)}, - "environment": map[string]any{"name": envName, "namespace": ns}, - "source": map[string]any{}, - }, - }} - - createdPkg, err := dyn.Resource(PackageGVR).Namespace(ns).Create(ctx, newPkg, metav1.CreateOptions{}) - if err != nil { - return nil, fmt.Errorf("create new package: %w", err) - } - - if err := unstructured.SetNestedField(fn.Object, req.Timeout, "spec", "functionTimeout"); err != nil { - _ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), newPkgName, metav1.DeleteOptions{}) - return nil, fmt.Errorf("set function timeout: %w", err) - } - - annotations := fn.GetAnnotations() - if annotations == nil { - annotations = make(map[string]string) - } - annotations[functionUpdatedAtAnnotation] = time.Now().UTC().Format(time.RFC3339) - fn.SetAnnotations(annotations) - - if err := unstructured.SetNestedField(fn.Object, map[string]any{ - "name": newPkgName, - "namespace": ns, - "resourceversion": createdPkg.GetResourceVersion(), - }, "spec", "package", "packageref"); err != nil { - _ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), newPkgName, metav1.DeleteOptions{}) - return nil, fmt.Errorf("set function packageref: %w", err) - } - - if _, err := dyn.Resource(FunctionGVR).Namespace(ns).Update(ctx, fn, metav1.UpdateOptions{}); err != nil { - _ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), newPkgName, metav1.DeleteOptions{}) - return nil, fmt.Errorf("update function %q: %w", name, err) - } - - if oldPkgName != "" && oldPkgName != newPkgName { - _ = dyn.Resource(PackageGVR).Namespace(ns).Delete(context.Background(), oldPkgName, metav1.DeleteOptions{}) - } - - return &UpdateFunctionResult{ - Updated: true, - Package: newPkgName, - }, nil -} - -// DeleteFunction orchestrates the deletion of a function and its related resources. -func DeleteFunction(ctx context.Context, dyn dynamic.Interface, ns, name string) error { - // Best-effort deletion of related resources. - _ = dyn.Resource(HTTPTrigGVR).Namespace(ns).Delete(ctx, name+"-route", metav1.DeleteOptions{}) - - fn, err := dyn.Resource(FunctionGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) - if err != nil && !apierrors.IsNotFound(err) { - return fmt.Errorf("get function for deletion: %w", err) - } - - if err == nil { - if pkgName, found, _ := unstructured.NestedString(fn.Object, "spec", "package", "packageref", "name"); found && pkgName != "" { - _ = dyn.Resource(PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{}) - } - } - - if err := dyn.Resource(FunctionGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { - return fmt.Errorf("delete function: %w", err) - } - - if fn != nil { - if envName, found, _ := unstructured.NestedString(fn.Object, "spec", "environment", "name"); found && envName != "" { - CleanupEnvironmentIfUnused(context.Background(), dyn, ns, envName) - } - } - - return nil -} - -func parseTTL(ttl string) (time.Time, error) { - duration, err := time.ParseDuration(ttl) - if err != nil { - return time.Time{}, err - } - return time.Now().UTC().Add(duration), nil -} diff --git a/console/internal/fission/function.go.tmp b/console/internal/fission/function.go.tmp deleted file mode 100644 index e69de29..0000000 diff --git a/console/internal/runtime/archive.go b/console/internal/runtime/archive.go deleted file mode 100644 index 7715047..0000000 --- a/console/internal/runtime/archive.go +++ /dev/null @@ -1,22 +0,0 @@ -package runtime - -// BuildDeployArchive creates a deployment archive for a given language and code. -// For Node.js, it creates a zip with an ESM wrapper. -// For script languages (PHP, Ruby), it creates a zip with the correct entrypoint filename. -// For others, it returns the code as is. -func BuildDeployArchive(lang, code string) ([]byte, error) { - switch lang { - case "nodejs": - return BuildJSDeployZip(code) - case "php": - return BuildScriptZip(code, "main.php") - case "ruby": - return BuildScriptZip(code, "handler.rb") - case "python", "go", "perl": // Go is a special case handled in CreateFunction, but for archive purposes it's just the source. - return []byte(code), nil - default: - // Allow unknown languages but treat them as simple scripts. - // The environment itself will fail later if the language is truly unsupported. - return []byte(code), nil - } -} diff --git a/console/ui/embed.go b/console/ui/embed.go index 69baf15..5f2b2d3 100644 --- a/console/ui/embed.go +++ b/console/ui/embed.go @@ -5,7 +5,7 @@ import ( "net/http" ) -//go:embed index.html cron.html +//go:embed index.html cron.html css js var content embed.FS func Handler() http.Handler { diff --git a/console/ui/index.html b/console/ui/index.html index 49f0dcf..6e49c25 100644 --- a/console/ui/index.html +++ b/console/ui/index.html @@ -12,328 +12,17 @@ window.location.replace('https://' + window.location.host + window.location.pathname + window.location.search + window.location.hash); } - + + + + + + + + + + + @@ -357,12 +46,12 @@
- + -
Токен: Личный кабинет → Профиль пользователя → Токены
+ placeholder="Введите токен или любой логин (не менее 6 символов)..."> +
Токен из ЛК: Личный кабинет → Профиль пользователя → Токены
Или введите любой логин не менее 6 символов для демо-доступа.
@@ -407,10 +96,11 @@ +
@@ -643,21 +335,19 @@

Как вызвать функцию вне веб-интерфейса

-
Обычный пользователь вызывает функцию через HTTP gateway console: /fn/.... -
-
Используется только ваш токен из личного кабинета в заголовке Authorization: Bearer - <token>.
+
Функции доступны через /fn/... с вашим токеном или демо-логином в заголовке Authorization: Bearer.
+
Gateway может возвращать редирект — используйте флаг -L в curl для его автоматического следования.
Работают обычные HTTP методы: GET, POST, PUT, DELETE и другие, если они разрешены в поле Методы.
-
export TOKEN="<ваш пользовательский токен>" - curl -sk -X POST https://fission.kube5s.ru/fn/ВАШ_ROUTE \ +
export TOKEN="<токен или демо-логин>" + curl -skL -X POST https://fission.kube5s.ru/fn/ВАШ_ROUTE \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"x":1,"y":2}'
-
export TOKEN="<ваш пользовательский токен>" - curl -sk https://fission.kube5s.ru/fn/ВАШ_ROUTE \ +
export TOKEN="<токен или демо-логин>" + curl -skL https://fission.kube5s.ru/fn/ВАШ_ROUTE \ -H "Authorization: Bearer $TOKEN"
import requests @@ -678,17 +368,16 @@

Где взять токен

-
Для обычного пользователя — используйте ваш пользовательский токен из личного кабинета: - Профиль пользователя → Токены.
-
Тот же пользовательский токен подходит и для входа в console, и для внешнего вызова через /fn/....
+
Токен из ЛК — Профиль пользователя → Токены. Подходит и для входа в console, и для вызова через /fn/.
+
Демо-логин — любая строка не менее 6 символов. Тот же логин даёт тот же namespace — ваше окружение сохраняется.
Токен удобно класть в переменную окружения и потом подставлять в curl как $TOKEN.
-
export TOKEN="<вставьте сюда токен из личного кабинета>"
+
export TOKEN="<вставьте сюда токен или демо-логин>"
-
+
+ v1.3.27
@@ -728,793 +417,6 @@ currentInvoke: null }; - function authHeaders() { - return { - 'X-Auth-Token': localStorage.getItem('auth_token') || '', - 'X-Auth-Env': localStorage.getItem('auth_env') || 'test' - }; - } - - async function getJSON(url) { - const r = await fetch(url, { headers: authHeaders() }); - if (r.status === 401) { doLogout(); throw new Error('Сессия истекла'); } - if (!r.ok) { - let msg = ''; - try { - const body = await r.json(); - msg = body.error || JSON.stringify(body); - } catch (_) { - msg = await r.text(); - } - throw new Error(url + ': ' + r.status + (msg ? ' - ' + msg : '')); - } - return r.json(); - } - - async function requestJSON(url, method, body) { - const r = await fetch(url, { - method: method, - headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), - body: body ? JSON.stringify(body) : undefined - }); - if (r.status === 401) { doLogout(); throw new Error('Сессия истекла'); } - let data = {}; - try { data = await r.json(); } catch (_) { } - if (!r.ok) { - throw new Error(data.error || (url + ': ' + r.status)); - } - return data; - } - - function setText(id, value) { - document.getElementById(id).textContent = String(value); - } - - function showStatus(message, kind) { - const el = document.getElementById('status'); - el.style.display = 'block'; - el.className = 'status ' + (kind || ''); - el.textContent = message; - } - - function clearStatus() { - const el = document.getElementById('status'); - el.style.display = 'none'; - el.className = 'status'; - el.textContent = ''; - } - - function parseMethods(v) { - const items = String(v || '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean); - return items.length ? Array.from(new Set(items)) : ['GET']; - } - - function parseTimeout(v) { - var n = Number(String(v || '').trim()); - if (!Number.isFinite(n) || n <= 0) return 60; - return Math.round(n); - } - - function llmGeneratedWarning(lang) { - var text = 'Сделано LLM. Не доверяй, проверяй!'; - switch (lang) { - case 'php': - return '// ' + text; - case 'nodejs': - case 'go': - return '// ' + text; - case 'ruby': - case 'python': - default: - return '# ' + text; - } - } - - function addLLMWarning(code, lang) { - var body = String(code || '').replace(/^\s+/, ''); - var warning = llmGeneratedWarning(lang); - if (!body) return warning; - if (body.indexOf(warning) === 0) return body; - if (lang === 'php' && body.indexOf('', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); - } - - function formatTimestamp(ts) { - if (!ts) return '—'; - var d = new Date(ts); - if (isNaN(d.getTime())) return String(ts); - return d.toLocaleString('ru-RU', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); - } - - function timestampCell(ts) { - var text = formatTimestamp(ts); - return '' + h(text) + ''; - } - - const LANG_TEMPLATES = { - python: { - entrypoint: 'main.main', - code: 'def main():\n return {"ok": True}' - }, - nodejs: { - entrypoint: 'main', - code: 'module.exports = async function(context) {\n return {\n status: 200,\n body: "hello from fission"\n };\n}' - }, - php: { - entrypoint: 'main.php::handler', - code: 'getBody()->write("hello from fission");\n}' - }, - ruby: { - entrypoint: 'handler', - code: '# frozen_string_literal: true\n\ndef handler\n "hello from fission"\nend' - } - }; - - function onLangChange() { - var lang = document.getElementById('c-lang').value; - var t = LANG_TEMPLATES[lang]; - if (t) { - document.getElementById('c-entry').value = t.entrypoint; - document.getElementById('c-code').value = t.code; - } - } - - function openCreate() { - document.getElementById('c-lang').value = 'python'; - onLangChange(); - document.getElementById('c-timeout').value = '60'; - document.getElementById('c-schedule-enabled').checked = false; - document.getElementById('c-cron').value = ''; - toggleScheduleFields('c'); - document.getElementById('create-modal').classList.add('open'); - document.getElementById('c-name').focus(); - } - - function closeCreate() { - document.getElementById('create-modal').classList.remove('open'); - } - - async function submitCreate() { - const btn = document.getElementById('c-submit'); - btn.disabled = true; - const progress = startTimedStatus('Создаём функцию...', 'Создание функции...', explainDelay); - try { - const name = document.getElementById('c-name').value.trim(); - if (!name) throw new Error('name is required'); - 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 - }); - - try { - await syncScheduleForFunction(name, 'c'); - } catch (scheduleErr) { - try { - await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE'); - } catch (rollbackErr) { - scheduleErr.message += '; rollback failed: ' + rollbackErr.message; - } - throw scheduleErr; - } - - closeCreate(); - progress.stop('\u0424\u0443\u043d\u043a\u0446\u0438\u044f \u0441\u043e\u0437\u0434\u0430\u043d\u0430: ' + name, 'ok'); - await reloadAll(); - } catch (e) { - progress.stop('\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f: ' + e.message, 'err'); - } finally { - btn.disabled = false; - } - } - - async function openEdit(name) { - try { - const fn = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name)); - S.currentEdit = fn; - document.getElementById('e-title').textContent = '\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435: ' + name; - document.getElementById('e-name').value = name; - document.getElementById('e-env').value = fn.environment || ''; - document.getElementById('e-entry').value = fn.entrypoint || ''; - document.getElementById('e-timeout').value = String(fn.timeout || 60); - document.getElementById('e-code').value = fn.code || ''; - var schedule = timeTriggerByFn(name); - document.getElementById('e-schedule-enabled').checked = !!schedule; - document.getElementById('e-cron').value = (schedule && schedule.spec && schedule.spec.cron) || ''; - toggleScheduleFields('e'); - // Определяем язык по имени environment для линтера - var envName = (fn.environment || '').toLowerCase(); - var lang = 'python'; - if (envName.includes('node')) lang = 'nodejs'; - else if (envName.includes('go')) lang = 'go'; - else if (envName.includes('ruby')) lang = 'ruby'; - else if (envName.includes('php')) lang = 'php'; - document.getElementById('e-lang-hidden').value = lang; - // сбросить предыдущий AI-результат - var aiRes = document.getElementById('e-ai-result'); - if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; } - var warnEl = document.getElementById('e-tf-warn'); - if (warnEl) { - var isTf = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || ''); - warnEl.style.display = isTf ? 'block' : 'none'; - } - document.getElementById('edit-modal').classList.add('open'); - } catch (e) { - showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0444\u0443\u043d\u043a\u0446\u0438\u0438: ' + e.message, 'err'); - } - } - - function closeEdit() { - document.getElementById('edit-modal').classList.remove('open'); - S.currentEdit = null; - } - - function openHelp() { - document.getElementById('help-modal').classList.add('open'); - } - - function closeHelp() { - document.getElementById('help-modal').classList.remove('open'); - } - - async function submitEdit() { - if (!S.currentEdit) return; - const btn = document.getElementById('e-submit'); - btn.disabled = true; - 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) - }); - - await syncScheduleForFunction(name, 'e'); - closeEdit(); - progress.stop('\u041a\u043e\u0434 \u043e\u0431\u043d\u043e\u0432\u043b\u0451\u043d: ' + name, 'ok'); - await reloadAll(); - } catch (e) { - progress.stop('\u041e\u0448\u0438\u0431\u043a\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f: ' + e.message, 'err'); - } finally { - btn.disabled = false; - } - } - - function openInvoke(name) { - S.currentInvoke = name; - document.getElementById('i-title').textContent = '\u0412\u044b\u0437\u043e\u0432: ' + name; - document.getElementById('i-resp').value = ''; - document.getElementById('i-status').textContent = ''; - document.getElementById('i-meta').style.display = 'none'; - document.getElementById('i-meta').textContent = ''; - document.getElementById('invoke-modal').classList.add('open'); - } - - function closeInvoke() { - document.getElementById('invoke-modal').classList.remove('open'); - S.currentInvoke = null; - } - - async function submitInvoke() { - if (!S.currentInvoke) return; - const btn = document.getElementById('i-submit'); - const statusEl = document.getElementById('i-status'); - const metaEl = document.getElementById('i-meta'); - const respEl = document.getElementById('i-resp'); - btn.disabled = true; - respEl.value = ''; - metaEl.style.display = 'none'; - metaEl.textContent = ''; - let elapsed = 0; - statusEl.textContent = 'Вызов...'; - const timer = setInterval(() => { - elapsed++; - if (elapsed < 5) { - statusEl.textContent = 'Вызов... ' + elapsed + 'с'; - } else if (elapsed < 10) { - statusEl.textContent = '⏳ Возможен cold start — прогрев пула... ' + elapsed + 'с'; - } else { - statusEl.textContent = '⏳ Возможны cold start или specialization... ' + elapsed + 'с'; - } - }, 1000); - try { - const raw = document.getElementById('i-body').value.trim(); - let parsed = {}; - if (raw) parsed = JSON.parse(raw); - const result = await requestJSON(API_BASE + '/functions/' + encodeURIComponent(S.currentInvoke) + '/invoke', 'POST', parsed); - var latencyMs = Number(result.latency_ms || 0); - var measuredSeconds = latencyMs > 0 ? Math.max(1, Math.round(latencyMs / 1000)) : elapsed; - statusEl.textContent = '✓ Выполнено за ' + formatSeconds(measuredSeconds); - metaEl.style.display = 'block'; - metaEl.textContent = [ - 'HTTP status: ' + (result.status || 'n/a'), - 'Latency: ' + (latencyMs || 'n/a') + ' ms', - 'Причина задержки: ' + explainDelay(measuredSeconds), - 'Invoke URL: ' + (result.invoke_url || 'n/a') - ].join('\n'); - respEl.value = JSON.stringify(result, null, 2); - } catch (e) { - statusEl.textContent = '✗ Ошибка после ' + formatSeconds(elapsed); - metaEl.style.display = 'block'; - metaEl.textContent = [ - 'Последняя ошибка: ' + e.message, - 'Вероятная причина задержки: ' + explainDelay(elapsed) - ].join('\n'); - respEl.value = 'Ошибка вызова: ' + e.message; - } finally { - clearInterval(timer); - btn.disabled = false; - } - } - - async function removeFn(name) { - var tfWarn = (/^tf-/.test(name)) ? '\n\n\u26a0\ufe0f \u042d\u0442\u0430 \u0444\u0443\u043d\u043a\u0446\u0438\u044f \u0443\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u0442\u0441\u044f Terraform. \u0423\u0434\u0430\u043b\u0435\u043d\u0438\u0435 \u043f\u0440\u0438\u0432\u0435\u0434\u0451\u0442 \u043a \u0440\u0430\u0441\u0441\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437\u0430\u0446\u0438\u0438 state!' : ''; - if (!confirm('\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u0444\u0443\u043d\u043a\u0446\u0438\u044e ' + name + '?' + tfWarn)) return; - var progress = startTimedStatus('\u0423\u0434\u0430\u043b\u044f\u0435\u043c \u0444\u0443\u043d\u043a\u0446\u0438\u044e...', '\u0423\u0434\u0430\u043b\u0435\u043d\u0438\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u0438...', explainDelay); - try { - await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE'); - progress.stop('\u0424\u0443\u043d\u043a\u0446\u0438\u044f \u0443\u0434\u0430\u043b\u0435\u043d\u0430: ' + name, 'ok'); - await reloadAll(); - } catch (e) { - progress.stop('\u041e\u0448\u0438\u0431\u043a\u0430 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u044f: ' + e.message, 'err'); - } - } - - async function reloadAll() { - try { - const [envs, pkgs, fns, http, times] = await Promise.all([ - getJSON(API_BASE + '/environments'), - getJSON(API_BASE + '/packages'), - getJSON(API_BASE + '/functions'), - getJSON(API_BASE + '/httptriggers'), - getJSON(API_BASE + '/timetriggers') - ]); - - S.envs = envs || []; - S.fns = fns || []; - S.httpTriggers = http || []; - S.timeTriggers = times || []; - - setText('env-count', envs.length || 0); - setText('pkg-count', pkgs.length || 0); - setText('fn-count', fns.length || 0); - setText('http-count', http.length || 0); - setText('cron-count', new Set((times || []).map(function (t) { - return t && t.spec && t.spec.functionref && t.spec.functionref.name; - }).filter(Boolean)).size); - - const rows = (fns || []).map(function (f) { - const spec = f.spec || {}; - const meta = f.metadata || {}; - const ann = meta.annotations || {}; - const env = (spec.environment && spec.environment.name) || '-'; - const pkg = (spec.package && spec.package.packageref && spec.package.packageref.name) || '-'; - const name = (f.metadata && f.metadata.name) || '-'; - const trig = httpTriggerByFn(name) || {}; - const timeTrig = timeTriggerByFn(name) || {}; - const route = (trig.spec && trig.spec.relativeurl) || '-'; - const methods = (trig.spec && trig.spec.methods) || []; - const chips = methods.map(function (m) { return '' + h(m) + ''; }).join(''); - const cron = (timeTrig.spec && timeTrig.spec.cron) || ''; - const cronCell = cron ? '' + h(cron) + '' : ''; - const createdAt = ann['fission-console/created-at'] || meta.creationTimestamp || '-'; - const updatedAt = ann['fission-console/updated-at'] || createdAt; - var isGo = /go[-_]env/.test(env); - var isTf = /^tf-/.test(name); - var tfBadge = (isGo || isTf) ? 'TF ' : ''; - var actions = tfBadge + - ' ' + - ' ' + - ''; - return '' + - '' + h(name) + '' + - '' + h(env) + '' + - '' + h(pkg) + '' + - '' + timestampCell(createdAt) + '' + - '' + timestampCell(updatedAt) + '' + - '' + h(route) + '' + - '' + chips + '' + - '' + cronCell + '' + - '' + actions + '' + - ''; - }).join(''); - - document.getElementById('fn-rows').innerHTML = rows || '\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439'; - if (!rows) { - document.getElementById('fn-rows').innerHTML = '\u041d\u0435\u0442 \u0444\u0443\u043d\u043a\u0446\u0438\u0439'; - } - } catch (e) { - document.getElementById('fn-rows').innerHTML = 'Load error: ' + e.message + ''; - showStatus('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438: ' + e.message, 'err'); - } - } - - function showLoginOverlay() { - document.getElementById('login-overlay').style.display = 'flex'; - } - - function hideLoginOverlay() { - document.getElementById('login-overlay').style.display = 'none'; - } - - async function doLogin() { - var btn = document.getElementById('l-btn'); - var errEl = document.getElementById('l-error'); - var token = (document.getElementById('l-token').value || '').trim(); - var env = document.getElementById('l-env').value; - if (!token) { errEl.textContent = 'Введите токен'; errEl.style.display = 'block'; return; } - btn.disabled = true; - errEl.style.display = 'none'; - try { - const res = await fetch(API_BASE + '/auth', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: token, env: env }) - }); - if (!res.ok) { - const d = await res.json().catch(function () { return {}; }); - throw new Error(d.error || 'Ошибка входа'); - } - localStorage.setItem('auth_token', token); - localStorage.setItem('auth_env', env); - setUserAvatar(token); - hideLoginOverlay(); - // Проверяем статус NS — если не ready, показываем init overlay - try { - var sr = await fetch(API_BASE + '/ns/status', { headers: { 'X-Auth-Token': token, 'X-Auth-Env': env } }); - var sd = await sr.json(); - if (!sd.ready) { - pollNSStatus(); - return; - } - } catch (_) { } - reloadAll(); - } catch (e) { - errEl.textContent = e.message; - errEl.style.display = 'block'; - } finally { - btn.disabled = false; - } - } - - function jwtEmail(token) { - try { - var payload = JSON.parse(atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'))); - return payload.email || payload.sub || null; - } catch (_) { return null; } - } - - function setUserAvatar(token) { - var el = document.getElementById('user-avatar'); - if (!el) return; - var email = token ? jwtEmail(token) : null; - if (email) { - el.textContent = email; - el.title = email; - el.style.fontSize = '0.7rem'; - el.style.minWidth = '60px'; - el.style.padding = '0 8px'; - } else { - el.textContent = 'N'; - el.title = ''; - el.style.fontSize = ''; - el.style.minWidth = ''; - el.style.padding = ''; - } - } - - function doLogout() { - localStorage.removeItem('auth_token'); - localStorage.removeItem('auth_env'); - try { document.getElementById('l-token').value = ''; } catch (_) { } - setUserAvatar(null); - showLoginOverlay(); - } - - async function aiCheck(codeId, langId, resultId) { - var code = document.getElementById(codeId).value.trim(); - var lang = (document.getElementById(langId) ? document.getElementById(langId).value : '') || 'python'; - var resEl = document.getElementById(resultId); - if (!code) { resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.textContent = 'Введите код для проверки.'; return; } - var btnId = codeId === 'c-code' ? 'c-ai-btn' : 'e-ai-btn'; - var btn = document.getElementById(btnId); - btn.disabled = true; - btn.textContent = '⏳ Проверяю...'; - resEl.style.display = 'block'; - resEl.style.background = 'var(--bg-alt)'; - resEl.style.color = 'var(--fg)'; - resEl.textContent = 'Проверяю код линтером...'; - try { - var data = await requestJSON(API_BASE + '/ai/check', 'POST', { language: lang, code: code }); - resEl.style.background = data.ok ? '#1a3a1a' : '#3a1a1a'; - resEl.style.color = data.ok ? '#8f8' : '#f88'; - resEl.textContent = data.result || '(пустой ответ)'; - } catch (e) { - resEl.style.background = '#3a2a00'; - resEl.style.color = '#ffa'; - resEl.textContent = 'Ошибка: ' + e.message; - } finally { - btn.disabled = false; - btn.textContent = '🔍 Проверить синтаксис линтером'; - } - } - - var _initPolling = false; - var _initTickBusy = false; - var _initStartedAt = 0; - - function initLog(msg) { - var el = document.getElementById('init-log'); - var ts = new Date().toLocaleTimeString('ru-RU'); - el.textContent += '[' + ts + '] ' + msg + '\n'; - el.scrollTop = el.scrollHeight; - } - - function formatSeconds(totalSeconds) { - totalSeconds = Math.max(0, Number(totalSeconds) || 0); - var minutes = Math.floor(totalSeconds / 60); - var seconds = totalSeconds % 60; - if (minutes <= 0) return seconds + 'с'; - return minutes + 'м ' + seconds + 'с'; - } - - function setInitElapsed(seconds) { - document.getElementById('init-elapsed').textContent = formatSeconds(seconds); - } - - function setInitCurrentStage(text) { - document.getElementById('init-current-stage').textContent = text || 'Ожидание старта'; - } - - function setBox(id, text) { - var el = document.getElementById(id); - if (!el) return; - if (!text) { - el.style.display = 'none'; - el.textContent = ''; - return; - } - el.style.display = 'block'; - el.textContent = text; - } - - function missingKeys(obj) { - return Object.keys(obj || {}).filter(function (key) { - return !obj[key]; - }); - } - - function firstPendingStage(stages) { - var list = stages || []; - for (var i = 0; i < list.length; i++) { - if (!list[i].done) return list[i].name; - } - return list.length ? list[list.length - 1].name : 'Ожидание старта'; - } - - function describeInitReason(debug) { - if (!debug) return 'Собираем диагностические данные по namespace и control-plane...'; - if (!debug.exists) return 'Ещё не создан Kubernetes namespace пользователя.'; - if (!debug.active) return 'Namespace создан, но Kubernetes ещё не перевёл его в состояние Active.'; - - var preparation = debug.preparation || {}; - if (!preparation.managedByConsole) return 'Namespace существует, но ещё не помечен как managed-by=fission-console.'; - if (!preparation.watcherLabel) return 'Namespace ещё не помечен label fission.io/managed=true для NSWatcher.'; - - var missingSAs = missingKeys(preparation.serviceAccounts); - if (missingSAs.length) return 'Не готовы bootstrap service accounts: ' + missingSAs.join(', ') + '.'; - - var missingRBs = missingKeys(preparation.roleBindings); - if (missingRBs.length) return 'Не готовы bootstrap role bindings: ' + missingRBs.join(', ') + '.'; - - var control = debug.controlPlane || {}; - if (!control.executor || !control.executor.ready) { - return 'Executor ещё не ready. Возможна задержка из-за cold start, image pull или specialization.'; - } - if (!control.router || !control.router.ready) { - return 'Router ещё не ready. HTTP маршрутизация пока не активна.'; - } - - return 'Ожидаем финальное подтверждение готовности control-plane.'; - } - - function summarizeInit(status, debug) { - var done = status && status.stages ? status.stages.filter(function (s) { return s.done; }).length : 0; - var namespace = debug && debug.namespace ? debug.namespace : 'не определён'; - return 'Namespace: ' + namespace + '. Завершено шагов: ' + done + '/3.'; - } - - function explainDelay(seconds) { - if (seconds < 5) return 'Обычная операция без заметной задержки.'; - if (seconds < 10) return 'Вероятен cold start: прогрев пула, image pull или ожидание specialization.'; - return 'Задержка выше нормы: возможны cold start, specialization или ожидание готовности control-plane.'; - } - - function startTimedStatus(startText, waitingPrefix, waitingHint) { - var startedAt = Date.now(); - showStatus(startText, ''); - var timer = setInterval(function () { - var elapsed = Math.max(1, Math.floor((Date.now() - startedAt) / 1000)); - var text = waitingPrefix + ' ' + formatSeconds(elapsed); - if (waitingHint) text += ' · ' + waitingHint(elapsed); - showStatus(text, ''); - }, 1000); - return { - stop: function (message, kind) { - clearInterval(timer); - var elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); - var text = message; - if (message) text += ' за ' + formatSeconds(elapsed); - showStatus(text, kind || ''); - } - }; - } - - function renderInitStages(stages) { - var el = document.getElementById('init-stages'); - el.innerHTML = stages.map(function (s) { - var icon = s.done ? '✅' : '⏳'; - var color = s.done ? '#4caf50' : '#8bc7ff'; - return '
' + icon + ' ' + s.name + '
'; - }).join(''); - } - - async function pollNSStatus() { - if (_initPolling) return; - _initPolling = true; - _initTickBusy = false; - _initStartedAt = Date.now(); - var overlay = document.getElementById('init-overlay'); - overlay.style.display = 'flex'; - document.getElementById('init-log').textContent = ''; - setInitElapsed(0); - setInitCurrentStage('Подготовка запуска'); - setBox('init-summary', 'Проверяем состояние namespace и control-plane...'); - setBox('init-reason', 'Если пользователь новый, возможны cold start и создание bootstrap ресурсов.'); - setBox('init-error', ''); - initLog('Запуск инициализации...'); - var attempt = 0; - var maxAttempts = 60; // 5 минут - var interval = setInterval(async function () { - if (_initTickBusy) return; - _initTickBusy = true; - attempt++; - try { - var elapsed = Math.floor((Date.now() - _initStartedAt) / 1000); - setInitElapsed(elapsed); - var statusResp = await fetch(API_BASE + '/ns/status', { headers: authHeaders() }); - var debugResp = await fetch(API_BASE + '/ns/debug', { headers: authHeaders() }); - var d = await statusResp.json(); - var dbg = await debugResp.json().catch(function () { return null; }); - if (d.stages) renderInitStages(d.stages); - setInitCurrentStage(firstPendingStage(d.stages)); - setBox('init-summary', summarizeInit(d, dbg)); - setBox('init-reason', describeInitReason(dbg)); - setBox('init-error', ''); - var done = d.stages ? d.stages.filter(function (s) { return s.done; }).length : 0; - initLog('Шагов завершено: ' + done + '/3'); - if (d.ready) { - initLog('✅ Готово! Загружаем консоль...'); - clearInterval(interval); - _initPolling = false; - _initTickBusy = false; - overlay.style.display = 'none'; - reloadAll(); - return; - } - } catch (e) { - setBox('init-error', 'Последняя ошибка: ' + e.message); - initLog('Ошибка опроса: ' + e.message); - } finally { - _initTickBusy = false; - } - if (attempt >= maxAttempts) { - clearInterval(interval); - _initPolling = false; - _initTickBusy = false; - setBox('init-error', 'Превышено время ожидания. Чаще всего это bootstrap RBAC, control-plane readiness или холодный старт.'); - initLog('⚠️ Превышено время ожидания. Попробуйте обновить страницу.'); - } - }, 5000); - } - - function checkAuth() { - var storedToken = localStorage.getItem('auth_token'); - if (!storedToken) { - showLoginOverlay(); - return; - } - setUserAvatar(storedToken); - hideLoginOverlay(); - reloadAll(); - } - checkAuth(); @@ -1553,97 +455,6 @@
- - diff --git a/console/ui/js/ai.js b/console/ui/js/ai.js index 5713c95..56f8719 100644 --- a/console/ui/js/ai.js +++ b/console/ui/js/ai.js @@ -1,4 +1,32 @@ -// AI-функции (линтер, генерация, объяснение) +/* ai.js — AI/LLM функции: проверка, генерация, объяснение, ассистент */ + +function llmGeneratedWarning(lang) { + var text = 'Сделано LLM. Не доверяй, проверяй!'; + switch (lang) { + case 'php': + return '// ' + text; + case 'nodejs': + case 'go': + return '// ' + text; + case 'ruby': + case 'python': + default: + return '# ' + text; + } +} + +function addLLMWarning(code, lang) { + var body = String(code || '').replace(/^\s+/, ''); + var warning = llmGeneratedWarning(lang); + if (!body) return warning; + if (body.indexOf(warning) === 0) return body; + if (lang === 'php' && body.indexOf(' s.trim().toUpperCase()).filter(Boolean); - return items.length ? Array.from(new Set(items)) : ['GET']; -} - -function parseTimeout(v) { - var n = Number(String(v || '').trim()); - if (!Number.isFinite(n) || n <= 0) return 60; - return Math.round(n); -} - -function llmGeneratedWarning(lang) { - var text = 'Сделано LLM. Не доверяй, проверяй!'; - switch (lang) { - case 'php': - return '// ' + text; - case 'nodejs': - case 'go': - return '// ' + text; - case 'ruby': - case 'python': - default: - return '# ' + text; - } -} - -function addLLMWarning(code, lang) { - var body = String(code || '').replace(/^\s+/, ''); - var warning = llmGeneratedWarning(lang); - if (!body) return warning; - if (body.indexOf(warning) === 0) return body; - if (lang === 'php' && body.indexOf(' { - elapsed++; - if (elapsed < 5) { - statusEl.textContent = 'Вызов... ' + elapsed + 'с'; - } else if (elapsed < 10) { - statusEl.textContent = '⏳ Возможен cold start — прогрев пула... ' + elapsed + 'с'; - } else { - statusEl.textContent = '⏳ Возможны cold start или specialization... ' + elapsed + 'с'; - } - }, 1000); - try { - const raw = document.getElementById('i-body').value.trim(); - let parsed = {}; - if (raw) parsed = JSON.parse(raw); - const result = await requestJSON(API_BASE + '/functions/' + encodeURIComponent(S.currentInvoke) + '/invoke', 'POST', parsed); - var latencyMs = Number(result.latency_ms || 0); - var measuredSeconds = latencyMs > 0 ? Math.max(1, Math.round(latencyMs / 1000)) : elapsed; - statusEl.textContent = '✓ Выполнено за ' + formatSeconds(measuredSeconds); - metaEl.style.display = 'block'; - metaEl.textContent = [ - 'HTTP status: ' + (result.status || 'n/a'), - 'Latency: ' + (latencyMs || 'n/a') + ' ms', - 'Причина задержки: ' + explainDelay(measuredSeconds), - 'Invoke URL: ' + (result.invoke_url || 'n/a') - ].join('\n'); - respEl.value = JSON.stringify(result, null, 2); - } catch (e) { - statusEl.textContent = '✗ Ошибка после ' + formatSeconds(elapsed); - metaEl.style.display = 'block'; - metaEl.textContent = [ - 'Последняя ошибка: ' + e.message, - 'Вероятная причина задержки: ' + explainDelay(elapsed) - ].join('\n'); - respEl.value = 'Ошибка вызова: ' + e.message; - } finally { - clearInterval(timer); - btn.disabled = false; - } -} - -async function removeFn(name) { - var tfWarn = (/^tf-/.test(name)) ? '\n\n⚠️ Эта функция управляется Terraform. Удаление приведёт к рассинхронизации state!' : ''; - if (!confirm('Удалить функцию ' + name + '?' + tfWarn)) return; - var progress = startTimedStatus('Удаляем функцию...', 'Удаление функции...', explainDelay); - try { - await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE'); - progress.stop('Функция удалена: ' + name, 'ok'); - await reloadAll(); - } catch (e) { - progress.stop('Ошибка удаления: ' + e.message, 'err'); - } -} +/* app.js — основная логика: загрузка данных, таблица функций */ async function reloadAll() { try { @@ -308,9 +43,9 @@ async function reloadAll() { var isTf = /^tf-/.test(name); var tfBadge = (isGo || isTf) ? 'TF ' : ''; var actions = tfBadge + - ' ' + - ' ' + - ''; + ' ' + + ' ' + + ''; return '' + '' + h(name) + '' + '' + h(env) + '' + @@ -325,157 +60,11 @@ async function reloadAll() { }).join(''); document.getElementById('fn-rows').innerHTML = rows || 'Нет функций'; + if (!rows) { + document.getElementById('fn-rows').innerHTML = 'Нет функций'; + } } catch (e) { document.getElementById('fn-rows').innerHTML = 'Load error: ' + e.message + ''; showStatus('Ошибка загрузки: ' + e.message, 'err'); } } - -// Инициализация и загрузка модулей -fetch('templates/modals/login.html') - .then(r => r.text()) - .then(html => { - document.body.insertAdjacentHTML('afterbegin', html); - }); - -// Инициализация функций -async function aiCheck(codeId, langId, resultId) { - // Функция перемещена в ai.js — не удаляй, это placeholder для обратной совместимости - console.error('aiCheck должна быть вызвана из ai.js, но она не загружена!'); -} - -// Init polling (перемещено в init.js) -var _initPolling = false; -var _initTickBusy = false; -var _initStartedAt = 0; - -function initLog(msg) { - // Функция перемещена в init.js -} - -function formatSeconds(totalSeconds) { - // Функция перемещена в init.js -} - -function setInitElapsed(seconds) { - // Функция перемещена в init.js -} - -function setInitCurrentStage(text) { - // Функция перемещена в init.js -} - -function setBox(id, text) { - // Функция перемещена в init.js -} - -function missingKeys(obj) { - // Функция перемещена в init.js -} - -function firstPendingStage(stages) { - // Функция перемещена в init.js -} - -function describeInitReason(debug) { - // Функция перемещена в init.js -} - -function summarizeInit(status, debug) { - // Функция перемещена в init.js -} - -function explainDelay(seconds) { - // Функция перемещена в init.js -} - -function startTimedStatus(startText, waitingPrefix, waitingHint) { - // Функция перемещена в init.js -} - -function renderInitStages(stages) { - // Функция перемещена в init.js -} - -async function pollNSStatus() { - // Функция перемещена в init.js -} - -function checkAuth() { - var storedToken = localStorage.getItem('auth_token'); - if (!storedToken) { - showLoginOverlay(); - return; - } - setUserAvatar(storedToken); - hideLoginOverlay(); - reloadAll(); -} - -// AI Ассистент (функции перемещены в ui.js) -var _assistantOpen = true; -function toggleAssistant() { - // Функция перемещена в ui.js -} - -async function askAssistant() { - // Функция перемещена в ui.js -} - -function showGenPrompt() { - // Функция перемещена в ai.js -} - -async function aiGenerate() { - // Функция перемещена в ai.js -} - -async function aiExplain(codeId, langId, resultId) { - // Функция перемещена в ai.js -} - -// Event delegation для data-* атрибутов -function initializeEventListeners() { - document.addEventListener('click', function(e) { - const target = e.target.closest('[data-onclick]'); - if (target) { - const handler = target.dataset.onclick; - try { - // Попытаемся вызвать как функцию или выполнить как код - eval(`${handler}`); - } catch (err) { - console.error('Error in onclick handler:', handler, err); - } - } - }); - - document.addEventListener('change', function(e) { - const target = e.target.closest('[data-onchange]'); - if (target) { - const handler = target.dataset.onchange; - try { - eval(`${handler}`); - } catch (err) { - console.error('Error in onchange handler:', handler, err); - } - } - }); - - document.addEventListener('keydown', function(e) { - const target = e.target.closest('[data-onkeydown]'); - if (target) { - const expr = target.dataset.onkeydown; - try { - // Выполняем выражение с доступом к event - eval(`(function(event) { ${expr} }).call(this, e)`); - } catch (err) { - console.error('Error in onkeydown handler:', err); - } - } - }); -} - -// Инициализация приложения -initializeEventListeners(); -checkAuth(); - diff --git a/console/ui/js/auth.js b/console/ui/js/auth.js index b099f06..9b8fdb7 100644 --- a/console/ui/js/auth.js +++ b/console/ui/js/auth.js @@ -1,4 +1,4 @@ -// Логика авторизации (логин/логаут, токен, email) +/* auth.js — авторизация */ function showLoginOverlay() { document.getElementById('login-overlay').style.display = 'flex'; @@ -13,7 +13,9 @@ async function doLogin() { var errEl = document.getElementById('l-error'); var token = (document.getElementById('l-token').value || '').trim(); var env = document.getElementById('l-env').value; - if (!token) { errEl.textContent = 'Введите токен'; errEl.style.display = 'block'; return; } + if (!token) { errEl.textContent = 'Введите токен или логин'; errEl.style.display = 'block'; return; } + var isJWT = token.split('.').length === 3 && !token.includes(' '); + if (!isJWT && token.length < 6) { errEl.textContent = 'Логин — не менее 6 символов'; errEl.style.display = 'block'; return; } btn.disabled = true; errEl.style.display = 'none'; try { @@ -26,8 +28,11 @@ async function doLogin() { const d = await res.json().catch(function () { return {}; }); throw new Error(d.error || 'Ошибка входа'); } + const data = await res.json(); localStorage.setItem('auth_token', token); localStorage.setItem('auth_env', env); + if (data.email) localStorage.setItem('auth_email', data.email); + else localStorage.removeItem('auth_email'); setUserAvatar(token); hideLoginOverlay(); // Проверяем статус NS — если не ready, показываем init overlay @@ -58,26 +63,43 @@ function jwtEmail(token) { function setUserAvatar(token) { var el = document.getElementById('user-avatar'); if (!el) return; - var email = token ? jwtEmail(token) : null; + var email = localStorage.getItem('auth_email') || (token ? jwtEmail(token) : null); if (email) { el.textContent = email; el.title = email; - el.style.fontSize = '0.7rem'; - el.style.minWidth = '60px'; - el.style.padding = '0 8px'; + el.style.fontSize = '0.75rem'; + el.style.minWidth = 'auto'; + el.style.padding = '0 10px'; + el.style.background = 'none'; + el.style.border = '1px solid var(--border)'; + el.style.borderRadius = '6px'; + el.style.color = 'var(--text-primary)'; + el.style.fontWeight = '400'; + el.style.width = 'auto'; + el.style.height = '30px'; } else { el.textContent = 'N'; el.title = ''; - el.style.fontSize = ''; - el.style.minWidth = ''; - el.style.padding = ''; + el.style.cssText = ''; } } function doLogout() { localStorage.removeItem('auth_token'); localStorage.removeItem('auth_env'); + localStorage.removeItem('auth_email'); try { document.getElementById('l-token').value = ''; } catch (_) { } setUserAvatar(null); showLoginOverlay(); } + +function checkAuth() { + var storedToken = localStorage.getItem('auth_token'); + if (!storedToken) { + showLoginOverlay(); + return; + } + setUserAvatar(storedToken); + hideLoginOverlay(); + reloadAll(); +} diff --git a/console/ui/js/config.js b/console/ui/js/config.js index 569c804..a8187d0 100644 --- a/console/ui/js/config.js +++ b/console/ui/js/config.js @@ -1,4 +1,4 @@ -// Константы и настройки +/* config.js — константы и шаблоны языков */ const LANG_TEMPLATES = { python: { diff --git a/console/ui/js/functions.js b/console/ui/js/functions.js index 7eec2d1..cc55407 100644 --- a/console/ui/js/functions.js +++ b/console/ui/js/functions.js @@ -1,4 +1,15 @@ -// CRUD-операции с функциями (создание, редактирование, удаление) +/* functions.js — CRUD операции с функциями */ + +function parseMethods(v) { + const items = String(v || '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean); + return items.length ? Array.from(new Set(items)) : ['GET']; +} + +function parseTimeout(v) { + var n = Number(String(v || '').trim()); + if (!Number.isFinite(n) || n <= 0) return 60; + return Math.round(n); +} function onLangChange() { var lang = document.getElementById('c-lang').value; @@ -56,11 +67,86 @@ async function submitCreate() { } closeCreate(); - progress.stop('\u0424\u0443\u043d\u043a\u0446\u0438\u044f \u0441\u043e\u0437\u0434\u0430\u043d\u0430: ' + name, 'ok'); + progress.stop('Функция создана: ' + name, 'ok'); await reloadAll(); } catch (e) { - progress.stop('\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f: ' + e.message, 'err'); + progress.stop('Ошибка создания: ' + e.message, 'err'); } finally { btn.disabled = false; } } + +async function openEdit(name) { + try { + const fn = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name)); + S.currentEdit = fn; + document.getElementById('e-title').textContent = 'Редактирование: ' + name; + document.getElementById('e-name').value = name; + document.getElementById('e-env').value = fn.environment || ''; + document.getElementById('e-entry').value = fn.entrypoint || ''; + document.getElementById('e-timeout').value = String(fn.timeout || 60); + document.getElementById('e-code').value = fn.code || ''; + var schedule = timeTriggerByFn(name); + document.getElementById('e-schedule-enabled').checked = !!schedule; + document.getElementById('e-cron').value = (schedule && schedule.spec && schedule.spec.cron) || ''; + toggleScheduleFields('e'); + var envName = (fn.environment || '').toLowerCase(); + var lang = 'python'; + if (envName.includes('node')) lang = 'nodejs'; + else if (envName.includes('go')) lang = 'go'; + else if (envName.includes('ruby')) lang = 'ruby'; + else if (envName.includes('php')) lang = 'php'; + document.getElementById('e-lang-hidden').value = lang; + var aiRes = document.getElementById('e-ai-result'); + if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; } + var warnEl = document.getElementById('e-tf-warn'); + if (warnEl) { + var isTf = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || ''); + warnEl.style.display = isTf ? 'block' : 'none'; + } + document.getElementById('edit-modal').classList.add('open'); + } catch (e) { + showStatus('Ошибка загрузки функции: ' + e.message, 'err'); + } +} + +function closeEdit() { + document.getElementById('edit-modal').classList.remove('open'); + S.currentEdit = null; +} + +async function submitEdit() { + if (!S.currentEdit) return; + const btn = document.getElementById('e-submit'); + btn.disabled = true; + 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) + }); + + await syncScheduleForFunction(name, 'e'); + closeEdit(); + progress.stop('Код обновлён: ' + name, 'ok'); + await reloadAll(); + } catch (e) { + progress.stop('Ошибка обновления: ' + e.message, 'err'); + } finally { + btn.disabled = false; + } +} + +async function removeFn(name) { + var tfWarn = (/^tf-/.test(name)) ? '\n\n⚠️ Эта функция управляется Terraform. Удаление приведёт к рассинхронизации state!' : ''; + if (!confirm('Удалить функцию ' + name + '?' + tfWarn)) return; + var progress = startTimedStatus('Удаляем функцию...', 'Удаление функции...', explainDelay); + try { + await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE'); + progress.stop('Функция удалена: ' + name, 'ok'); + await reloadAll(); + } catch (e) { + progress.stop('Ошибка удаления: ' + e.message, 'err'); + } +} diff --git a/console/ui/js/init.js b/console/ui/js/init.js index 6bf3135..f191bbe 100644 --- a/console/ui/js/init.js +++ b/console/ui/js/init.js @@ -1,6 +1,5 @@ -// Инициализация окружения +/* init.js — инициализация namespace, утилиты таймера и статуса */ -// Init polling var _initPolling = false; var _initTickBusy = false; var _initStartedAt = 0; @@ -136,7 +135,7 @@ async function pollNSStatus() { setBox('init-error', ''); initLog('Запуск инициализации...'); var attempt = 0; - var maxAttempts = 60; + var maxAttempts = 60; // 5 минут var interval = setInterval(async function () { if (_initTickBusy) return; _initTickBusy = true; diff --git a/console/ui/js/modals.js b/console/ui/js/modals.js index ca79f5f..a8a4e2d 100644 --- a/console/ui/js/modals.js +++ b/console/ui/js/modals.js @@ -1,43 +1,4 @@ -// Модальные окна - -async function openEdit(name) { - try { - const fn = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name)); - S.currentEdit = fn; - document.getElementById('e-title').textContent = 'Редактирование: ' + name; - document.getElementById('e-name').value = name; - document.getElementById('e-env').value = fn.environment || ''; - document.getElementById('e-entry').value = fn.entrypoint || ''; - document.getElementById('e-timeout').value = String(fn.timeout || 60); - document.getElementById('e-code').value = fn.code || ''; - var schedule = timeTriggerByFn(name); - document.getElementById('e-schedule-enabled').checked = !!schedule; - document.getElementById('e-cron').value = (schedule && schedule.spec && schedule.spec.cron) || ''; - toggleScheduleFields('e'); - var envName = (fn.environment || '').toLowerCase(); - var lang = 'python'; - if (envName.includes('node')) lang = 'nodejs'; - else if (envName.includes('go')) lang = 'go'; - else if (envName.includes('ruby')) lang = 'ruby'; - else if (envName.includes('php')) lang = 'php'; - document.getElementById('e-lang-hidden').value = lang; - var aiRes = document.getElementById('e-ai-result'); - if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; } - var warnEl = document.getElementById('e-tf-warn'); - if (warnEl) { - var isTf = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || ''); - warnEl.style.display = isTf ? 'block' : 'none'; - } - document.getElementById('edit-modal').classList.add('open'); - } catch (e) { - showStatus('Ошибка загрузки функции: ' + e.message, 'err'); - } -} - -function closeEdit() { - document.getElementById('edit-modal').classList.remove('open'); - S.currentEdit = null; -} +/* modals.js — управление модальными окнами Help и Invoke */ function openHelp() { document.getElementById('help-modal').classList.add('open'); @@ -47,29 +8,6 @@ function closeHelp() { document.getElementById('help-modal').classList.remove('open'); } -async function submitEdit() { - if (!S.currentEdit) return; - const btn = document.getElementById('e-submit'); - btn.disabled = true; - 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) - }); - - await syncScheduleForFunction(name, 'e'); - closeEdit(); - progress.stop('Код обновлён: ' + name, 'ok'); - await reloadAll(); - } catch (e) { - progress.stop('Ошибка обновления: ' + e.message, 'err'); - } finally { - btn.disabled = false; - } -} - function openInvoke(name) { S.currentInvoke = name; document.getElementById('i-title').textContent = 'Вызов: ' + name; @@ -136,16 +74,3 @@ async function submitInvoke() { btn.disabled = false; } } - -async function removeFn(name) { - var tfWarn = (/^tf-/.test(name)) ? '\n\n⚠️ Эта функция управляется Terraform. Удаление приведёт к рассинхронизации state!' : ''; - if (!confirm('Удалить функцию ' + name + '?' + tfWarn)) return; - var progress = startTimedStatus('Удаляем функцию...', 'Удаление функции...', explainDelay); - try { - await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE'); - progress.stop('Функция удалена: ' + name, 'ok'); - await reloadAll(); - } catch (e) { - progress.stop('Ошибка удаления: ' + e.message, 'err'); - } -} diff --git a/console/ui/js/triggers.js b/console/ui/js/triggers.js index 249f8e5..24fc7fb 100644 --- a/console/ui/js/triggers.js +++ b/console/ui/js/triggers.js @@ -1,4 +1,4 @@ -// HTTP и Cron триггеры +/* triggers.js — HTTP и Cron триггеры */ function httpTriggerByFn(fnName) { return (S.httpTriggers || []).find(function (t) { diff --git a/console/ui/js/ui.js b/console/ui/js/ui.js index 4b26bc2..b7ba17d 100644 --- a/console/ui/js/ui.js +++ b/console/ui/js/ui.js @@ -1,4 +1,8 @@ -// UI-вспомогательные функции +/* ui.js — UI-вспомогательные функции */ + +function setText(id, value) { + document.getElementById(id).textContent = String(value); +} function showStatus(message, kind) { const el = document.getElementById('status'); @@ -14,10 +18,6 @@ function clearStatus() { el.textContent = ''; } -function setText(id, value) { - document.getElementById(id).textContent = String(value); -} - function h(v) { return String(v == null ? '' : v) .replaceAll('&', '&') @@ -45,33 +45,3 @@ function timestampCell(ts) { var text = formatTimestamp(ts); return '' + h(text) + ''; } - -// AI Ассистент -var _assistantOpen = true; -function toggleAssistant() { - _assistantOpen = !_assistantOpen; - document.getElementById('assistant-body').style.display = _assistantOpen ? 'flex' : 'none'; - document.getElementById('assistant-toggle-icon').textContent = _assistantOpen ? '▲' : '▼'; -} - -async function askAssistant() { - var inp = document.getElementById('assistant-input'); - var msgs = document.getElementById('assistant-messages'); - var q = inp.value.trim(); - if (!q) return; - inp.value = ''; - msgs.textContent += '\n\n👤 ' + q + '\n⏳ ...'; - msgs.scrollTop = msgs.scrollHeight; - try { - var r = await fetch('/console/api/ai/ask', { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...authHeaders() }, - body: JSON.stringify({ mode: 'chat', question: q }) - }); - var d = await r.json(); - msgs.textContent = msgs.textContent.replace('⏳ ...', '🤖 ' + (d.answer || d.error || 'Нет ответа')); - } catch (e) { - msgs.textContent = msgs.textContent.replace('⏳ ...', '❌ Ошибка: ' + e.message); - } - msgs.scrollTop = msgs.scrollHeight; -} diff --git a/console/ui/templates/components/ai-assistant.html b/console/ui/templates/components/ai-assistant.html deleted file mode 100644 index bd94065..0000000 --- a/console/ui/templates/components/ai-assistant.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/console/ui/templates/components/navbar.html b/console/ui/templates/components/navbar.html deleted file mode 100644 index 99113ba..0000000 --- a/console/ui/templates/components/navbar.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/console/ui/templates/components/status-cards.html b/console/ui/templates/components/status-cards.html deleted file mode 100644 index a4e1a7f..0000000 --- a/console/ui/templates/components/status-cards.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/console/ui/templates/modals/create-function.html b/console/ui/templates/modals/create-function.html deleted file mode 100644 index 1affe59..0000000 --- a/console/ui/templates/modals/create-function.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/console/ui/templates/modals/edit-function.html b/console/ui/templates/modals/edit-function.html deleted file mode 100644 index 31020b0..0000000 --- a/console/ui/templates/modals/edit-function.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/console/ui/templates/modals/help.html b/console/ui/templates/modals/help.html deleted file mode 100644 index 62e5261..0000000 --- a/console/ui/templates/modals/help.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/console/ui/templates/modals/init-overlay.html b/console/ui/templates/modals/init-overlay.html deleted file mode 100644 index dcf55ca..0000000 --- a/console/ui/templates/modals/init-overlay.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/console/ui/templates/modals/invoke.html b/console/ui/templates/modals/invoke.html deleted file mode 100644 index 8fff8a0..0000000 --- a/console/ui/templates/modals/invoke.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/console/ui/templates/modals/login.html b/console/ui/templates/modals/login.html deleted file mode 100644 index e7015a4..0000000 --- a/console/ui/templates/modals/login.html +++ /dev/null @@ -1,32 +0,0 @@ - -