fix(cron): timer auth chain via console gateway, bump image to v1.3.9 - Added handleFissionFunctionGateway in handlers.go: accepts unauthenticated POST /fission-function/<ns>/<fn>/, 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.
This commit is contained in:
@@ -52,7 +52,7 @@ spec:
|
|||||||
serviceAccountName: fission-console
|
serviceAccountName: fission-console
|
||||||
containers:
|
containers:
|
||||||
- name: console
|
- name: console
|
||||||
image: naeel/fission-console:v1.3.8
|
image: naeel/fission-console:v1.3.9
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8090
|
- containerPort: 8090
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -739,6 +739,114 @@ func buildInternalInvokeURL(routerURL, namespace, functionName string) string {
|
|||||||
return fmt.Sprintf("%s/fission-function/%s/%s", routerURL, namespace, functionName)
|
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.
|
// handleInvokeRoute даёт пользователю прямой HTTP gateway к своей функции по route.
|
||||||
// Внешний контракт: /fn/<route> + Authorization: Bearer <user-token>.
|
// Внешний контракт: /fn/<route> + Authorization: Bearer <user-token>.
|
||||||
func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleInvokeRoute(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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/packages", auth(s.handleList(fission.PackageGVR)))
|
||||||
mux.HandleFunc("/console/api/functions", auth(s.handleFunctionsRoot))
|
mux.HandleFunc("/console/api/functions", auth(s.handleFunctionsRoot))
|
||||||
mux.HandleFunc("/console/api/functions/", auth(s.handleFunctionsAction))
|
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("/fn/", auth(s.handleInvokeRoute))
|
||||||
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
|
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
|
||||||
mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot))
|
mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot))
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const (
|
|||||||
timeTriggerDefaultMethod = http.MethodPost
|
timeTriggerDefaultMethod = http.MethodPost
|
||||||
timeTriggerDefaultSubPath = "/"
|
timeTriggerDefaultSubPath = "/"
|
||||||
timeTriggerMaxNameLen = 63
|
timeTriggerMaxNameLen = 63
|
||||||
|
cronGatewayURL = "http://fission-console.fission.svc.cluster.local"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validTimeTriggerMethods = map[string]struct{}{
|
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"}
|
deployGVR := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
|
||||||
deploy, err := s.dyn.Resource(deployGVR).Namespace(fissionSystemNamespace()).Get(ctx, "timer", metav1.GetOptions{})
|
deploy, err := s.dyn.Resource(deployGVR).Namespace(fissionSystemNamespace()).Get(ctx, "timer", metav1.GetOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return fmt.Errorf("get timer deployment: %w", err)
|
return fmt.Errorf("get timer deployment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +341,8 @@ func (s *Server) ensureTimerWatchesNamespace(ctx context.Context, userNS string)
|
|||||||
resourceNamespaces := []string{"default"}
|
resourceNamespaces := []string{"default"}
|
||||||
defaultIdx := -1
|
defaultIdx := -1
|
||||||
resourceIdx := -1
|
resourceIdx := -1
|
||||||
|
routerIdx := -1
|
||||||
|
changed := false
|
||||||
for i, item := range envList {
|
for i, item := range envList {
|
||||||
env, ok := item.(map[string]any)
|
env, ok := item.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -353,6 +359,13 @@ func (s *Server) ensureTimerWatchesNamespace(ctx context.Context, userNS string)
|
|||||||
case "FISSION_RESOURCE_NAMESPACES":
|
case "FISSION_RESOURCE_NAMESPACES":
|
||||||
resourceIdx = i
|
resourceIdx = i
|
||||||
resourceNamespaces = splitCSVNamespaces(value)
|
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)
|
resourceNamespaces = ensureDefaultFirst(resourceNamespaces, defaultNS)
|
||||||
joined := strings.Join(resourceNamespaces, ",")
|
joined := strings.Join(resourceNamespaces, ",")
|
||||||
|
|
||||||
changed := false
|
|
||||||
if defaultIdx >= 0 {
|
if defaultIdx >= 0 {
|
||||||
env := envList[defaultIdx].(map[string]any)
|
env := envList[defaultIdx].(map[string]any)
|
||||||
if env["value"] != defaultNS {
|
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 {
|
if !changed {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/<namespace>/<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/<ns>/<fn>/
|
||||||
|
└─► console gateway (getRouterToken + proxy)
|
||||||
|
└─► POST http://router.fission/fission-function/<ns>/<fn>/ [Authorization: Bearer <jwt>]
|
||||||
|
└─► 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.
|
||||||
Reference in New Issue
Block a user