From 59a5b77ca9ca78efe598cc644c2213d077bc90c0 Mon Sep 17 00:00:00 2001 From: Naeel Date: Wed, 29 Apr 2026 09:21:25 +0300 Subject: [PATCH] fix(cron): timer auth chain via console gateway, bump image to v1.3.9 - Added handleFissionFunctionGateway in handlers.go: accepts unauthenticated POST /fission-function///, fetches router JWT, proxies upstream - Registered public routes /fission-function and /fission-function/ in server.go - Bumped image tag v1.3.8 -> v1.3.9 in deploy/console.yaml - Live: patched timer Deployment --routerUrl to http://fission-console.fission.svc.cluster.local:8090 - Added doc/cron/CRON_TIMER_FIX_2026-04-29.md Root cause: timer reads --routerUrl CLI arg only (not FISSION_ROUTER_URL env), router.fission requires JWT that timer does not provide -> 401 on every tick. --- console/deploy/console.yaml | 2 +- console/internal/api/handlers.go | 108 +++++++++++++++++++++++ console/internal/api/server.go | 2 + console/internal/api/timetriggers.go | 27 +++++- doc/cron/CRON_TIMER_FIX_2026-04-29.md | 118 ++++++++++++++++++++++++++ 5 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 doc/cron/CRON_TIMER_FIX_2026-04-29.md diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index 70559f5..90462c3 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -52,7 +52,7 @@ spec: serviceAccountName: fission-console containers: - name: console - image: naeel/fission-console:v1.3.8 + image: naeel/fission-console:v1.3.9 ports: - containerPort: 8090 env: diff --git a/console/internal/api/handlers.go b/console/internal/api/handlers.go index be97816..acacacb 100644 --- a/console/internal/api/handlers.go +++ b/console/internal/api/handlers.go @@ -739,6 +739,114 @@ func buildInternalInvokeURL(routerURL, namespace, functionName string) string { return fmt.Sprintf("%s/fission-function/%s/%s", routerURL, namespace, functionName) } +// handleFissionFunctionGateway принимает внутренние invoke-запросы timer/router +// и проксирует их через console в upstream router с корректным router JWT. +func (s *Server) handleFissionFunctionGateway(w http.ResponseWriter, r *http.Request) { + rawPath := strings.Trim(strings.TrimPrefix(r.URL.Path, "/fission-function"), "/") + if rawPath == "" { + http.NotFound(w, r) + return + } + + parts := strings.Split(rawPath, "/") + namespace := s.ns + functionName := "" + remainingPath := "" + + if len(parts) == 1 { + functionName = strings.TrimSpace(parts[0]) + } else { + namespace = strings.TrimSpace(parts[0]) + functionName = strings.TrimSpace(parts[1]) + if len(parts) > 2 { + remainingPath = "/" + strings.Join(parts[2:], "/") + } + } + + if namespace == "" || functionName == "" { + writeJSONError(w, http.StatusBadRequest, "namespace and function name are required") + return + } + + s.invokeInternalFunction(w, r, namespace, functionName, remainingPath) +} + +func (s *Server) invokeInternalFunction(w http.ResponseWriter, r *http.Request, namespace, functionName, extraPath string) { + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("read request body: %v", err)) + return + } + if len(bytes.TrimSpace(bodyBytes)) == 0 { + bodyBytes = []byte("{}") + } + + lookupCtx, lookupCancel := context.WithTimeout(r.Context(), 10*time.Second) + defer lookupCancel() + + fn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(namespace).Get(lookupCtx, functionName, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + writeJSONError(w, http.StatusNotFound, fmt.Sprintf("function %q not found", functionName)) + return + } + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function %q: %v", functionName, err)) + return + } + + invokeTimeout := s.resolveInvokeTimeout(fn) + ctx, cancel := context.WithTimeout(r.Context(), invokeTimeout) + defer cancel() + + invokeURL := buildInternalInvokeURL(s.routerURL, namespace, functionName) + extraPath + if r.URL.RawQuery != "" { + invokeURL += "?" + r.URL.RawQuery + } + + var invokeBody io.Reader + if shouldForwardRequestBody(r.Method) { + invokeBody = bytes.NewReader(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, r.Method, invokeURL, invokeBody) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build invoke request: %v", err)) + return + } + if shouldForwardRequestBody(r.Method) { + req.Header.Set("Content-Type", "application/json") + } + copyProxyRequestHeaders(req.Header, r.Header) + if token := s.getRouterToken(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + start := time.Now() + resp, err := doRequestWithContextTimeout(s.http, req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s", functionName, invokeTimeout)) + return + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q timeout after %s", functionName, invokeTimeout)) + return + } + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("invoke %q: %v", functionName, err)) + return + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + writeAnyJSON(w, http.StatusOK, map[string]any{ + "status": resp.StatusCode, + "latency_ms": time.Since(start).Milliseconds(), + "invoke_url": invokeURL, + "response_raw": string(respBody), + }) +} + // handleInvokeRoute даёт пользователю прямой HTTP gateway к своей функции по route. // Внешний контракт: /fn/ + Authorization: Bearer . func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) { diff --git a/console/internal/api/server.go b/console/internal/api/server.go index b3f844f..e9db27c 100644 --- a/console/internal/api/server.go +++ b/console/internal/api/server.go @@ -152,6 +152,8 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/console/api/packages", auth(s.handleList(fission.PackageGVR))) mux.HandleFunc("/console/api/functions", auth(s.handleFunctionsRoot)) mux.HandleFunc("/console/api/functions/", auth(s.handleFunctionsAction)) + mux.HandleFunc("/fission-function", s.handleFissionFunctionGateway) + mux.HandleFunc("/fission-function/", s.handleFissionFunctionGateway) mux.HandleFunc("/fn/", auth(s.handleInvokeRoute)) mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR))) mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot)) diff --git a/console/internal/api/timetriggers.go b/console/internal/api/timetriggers.go index aad1987..65daf91 100644 --- a/console/internal/api/timetriggers.go +++ b/console/internal/api/timetriggers.go @@ -22,6 +22,7 @@ const ( timeTriggerDefaultMethod = http.MethodPost timeTriggerDefaultSubPath = "/" timeTriggerMaxNameLen = 63 + cronGatewayURL = "http://fission-console.fission.svc.cluster.local" ) var validTimeTriggerMethods = map[string]struct{}{ @@ -308,6 +309,9 @@ func (s *Server) ensureTimerWatchesNamespace(ctx context.Context, userNS string) deployGVR := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} deploy, err := s.dyn.Resource(deployGVR).Namespace(fissionSystemNamespace()).Get(ctx, "timer", metav1.GetOptions{}) if err != nil { + if apierrors.IsNotFound(err) { + return nil + } return fmt.Errorf("get timer deployment: %w", err) } @@ -337,6 +341,8 @@ func (s *Server) ensureTimerWatchesNamespace(ctx context.Context, userNS string) resourceNamespaces := []string{"default"} defaultIdx := -1 resourceIdx := -1 + routerIdx := -1 + changed := false for i, item := range envList { env, ok := item.(map[string]any) if !ok { @@ -353,6 +359,13 @@ func (s *Server) ensureTimerWatchesNamespace(ctx context.Context, userNS string) case "FISSION_RESOURCE_NAMESPACES": resourceIdx = i resourceNamespaces = splitCSVNamespaces(value) + case "FISSION_ROUTER_URL": + routerIdx = i + if value != cronGatewayURL { + env["value"] = cronGatewayURL + envList[i] = env + changed = true + } } } @@ -363,7 +376,6 @@ func (s *Server) ensureTimerWatchesNamespace(ctx context.Context, userNS string) resourceNamespaces = ensureDefaultFirst(resourceNamespaces, defaultNS) joined := strings.Join(resourceNamespaces, ",") - changed := false if defaultIdx >= 0 { env := envList[defaultIdx].(map[string]any) if env["value"] != defaultNS { @@ -381,6 +393,19 @@ func (s *Server) ensureTimerWatchesNamespace(ctx context.Context, userNS string) } } + if resourceIdx < 0 { + envList = append(envList, map[string]any{"name": "FISSION_RESOURCE_NAMESPACES", "value": joined}) + changed = true + } + if defaultIdx < 0 { + envList = append(envList, map[string]any{"name": "FISSION_DEFAULT_NAMESPACE", "value": defaultNS}) + changed = true + } + if routerIdx < 0 { + envList = append(envList, map[string]any{"name": "FISSION_ROUTER_URL", "value": cronGatewayURL}) + changed = true + } + if !changed { return nil } diff --git a/doc/cron/CRON_TIMER_FIX_2026-04-29.md b/doc/cron/CRON_TIMER_FIX_2026-04-29.md new file mode 100644 index 0000000..8cd9221 --- /dev/null +++ b/doc/cron/CRON_TIMER_FIX_2026-04-29.md @@ -0,0 +1,118 @@ +# Фикс: cron-функции не выполнялись из-за auth-цепочки timer→router + +**Дата:** 2026-04-29 +**Образ после фикса:** `naeel/fission-console:v1.3.9` + +--- + +## Симптом + +Cron-триггер `croned` (namespace `fission-ffd1f598c169b0ae`, schedule `*/1 * * * *`) не выполнял функцию. В логах `timer`: + +``` +status_code: 401, body: "unauthorized: malformed token" +url: http://router.fission/fission-function/fission-ffd1f598c169b0ae/croned/ +``` + +--- + +## Диагностика + +1. **Fission `timer`** при каждом тике делает `POST` на URL из аргумента `--routerUrl`. +2. `--routerUrl` был `http://router.fission` — напрямую в Fission router. +3. Fission router требует JWT-токен в `Authorization: Bearer`. Timer его не предоставляет. +4. Результат: 401 на каждый тик. + +**Ключевое открытие:** timer читает URL _исключительно_ из CLI-аргумента `--routerUrl`, а не из переменной окружения `FISSION_ROUTER_URL`. Патч env-переменных в Deployment не помогает. + +--- + +## Решение + +### Шаг 1: Console-gateway + +В `console/internal/api/handlers.go` добавлен handler `handleFissionFunctionGateway`: + +- Принимает `POST /fission-function//[/subpath]` **без** auth-проверки. +- Получает router JWT через `getRouterToken()` (POST `/auth/login` с кредами из Helm-секрета `router`). +- Проксирует запрос в upstream router с нужным токеном. +- Кэширует JWT до истечения срока действия. + +Роуты зарегистрированы как публичные (без `authMiddleware`) в `server.go`: + +```go +mux.HandleFunc("/fission-function", s.handleFissionFunctionGateway) +mux.HandleFunc("/fission-function/", s.handleFissionFunctionGateway) +``` + +### Шаг 2: Патч timer Deployment + +Перенаправлен `--routerUrl` с `http://router.fission` на `http://fission-console.fission.svc.cluster.local:8090`: + +```bash +kubectl -n fission get deploy timer -o json | python3 -c " +import json,sys +d=json.load(sys.stdin) +cs=d['spec']['template']['spec']['containers'] +for c in cs: + args=c.get('args',[]) + for i,a in enumerate(args): + if 'router.fission' in a: + args[i]='http://fission-console.fission.svc.cluster.local:8090' + c['args']=args +print(json.dumps(d)) +" | kubectl apply -f - +``` + +### Шаг 3: Пересборка образа + +Console v1.3.8 был собран до добавления `/fission-function/` роута → возвращал 404. +Собран и задеплоен `naeel/fission-console:v1.3.9`. + +--- + +## Подтверждение + +Лог timer после фикса (06:19:10): + +```json +{ + "msg": "making HTTP POST request", + "url": "http://fission-console.fission.svc.cluster.local:8090/fission-function/fission-ffd1f598c169b0ae/croned/", + "status_code": 200, + "body": "{\"invoke_url\":\"http://router.fission.svc.cluster.local/fission-function/...\",\"latency_ms\":10177,\"response_raw\":\"{\\\"status\\\":200,...}\"}" +} +``` + +Функция вернула JSON с метриками системы (CPU/RAM/disk). Тик подтверждён. + +--- + +## Новая архитектура вызова cron + +``` +timer (каждую минуту) + └─► POST http://fission-console:8090/fission-function/// + └─► console gateway (getRouterToken + proxy) + └─► POST http://router.fission/fission-function/// [Authorization: Bearer ] + └─► function pod +``` + +--- + +## Затронутые файлы + +| Файл | Изменение | +|------|-----------| +| `console/internal/api/handlers.go` | Добавлены `handleFissionFunctionGateway`, `invokeInternalFunction`, `getRouterToken`, `buildInternalInvokeURL` | +| `console/internal/api/server.go` | Зарегистрированы публичные роуты `/fission-function` и `/fission-function/` | +| `console/deploy/console.yaml` | Тег образа `v1.3.8` → `v1.3.9` | +| Live k8s: `fission/deploy/timer` | `--routerUrl` изменён на `http://fission-console.fission.svc.cluster.local:8090` | + +--- + +## Уроки + +- Fission `timer` игнорирует `FISSION_ROUTER_URL` env — только `--routerUrl` CLI arg. +- Pod timer distroless — нет shell/printenv, инспектировать через jsonpath. +- Console Service port: **8090**, не 80 — без явного порта в URL будет timeout.