// 2026-03-18 // main.go — глобальный HTTP сервис листинга функций пользователя. // Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей. // Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml. // // Логика: // 1. Принимает Authorization: Bearer {jwt} от пользователя // 2. Извлекает sub из JWT (без проверки подписи — trusted perimeter за Ingress) // 3. Вычисляет namespace = SHA256(sub)[:8] hex (та же логика что в операторе) // 4. Вызывает внутренний API оператора: GET /v1/namespaces/{ns}/functions + /triggers // 5. Возвращает plain text — человекочитаемый список // // Env vars: // SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090) // SLESS_EXTERNAL_URL — публичный базовый URL для корректных ссылок на функции // SLESS_EXCLUDE — comma-separated список имён функций, скрытых из листинга // PORT — порт сервера (default: 8090) package main import ( "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "log" "net/http" "os" "sort" "strings" ) // fnResponse — ответ /v1/namespaces/{ns}/functions (подмножество полей оператора) type fnResponse struct { Name string `json:"name"` Runtime string `json:"runtime"` Phase string `json:"phase"` Message string `json:"message"` CreatedAt string `json:"created_at"` LastBuiltAt string `json:"last_built_at"` } // trResponse — ответ /v1/namespaces/{ns}/triggers type trResponse struct { Name string `json:"name"` Type string `json:"type"` FunctionRef string `json:"function"` Schedule string `json:"schedule"` Enabled bool `json:"enabled"` Active bool `json:"active"` URL string `json:"url"` } func main() { operatorURL := strings.TrimRight(env("SLESS_OPERATOR_URL", "http://sless-operator.sless.svc.cluster.local:9090"), "/") externalURL := strings.TrimRight(env("SLESS_EXTERNAL_URL", ""), "/") port := env("PORT", "8090") // serviceToken — токен сервиса для запросов к оператору по /funcs/ (без токена пользователя) serviceToken := os.Getenv("SLESS_SERVICE_TOKEN") exclude := map[string]bool{} for _, n := range strings.Split(os.Getenv("SLESS_EXCLUDE"), ",") { if n = strings.TrimSpace(n); n != "" { exclude[n] = true } } http.HandleFunc("/funcs", handler(operatorURL, externalURL, serviceToken, exclude)) // /health — для liveness/readiness probe без Bearer токена http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintln(w, "ok") }) // /funcs/ — без токена, namespace виден в URL всех функций http.HandleFunc("/funcs/", handler(operatorURL, externalURL, serviceToken, exclude)) log.Printf("sless-funcs-service listening on :%s (operator: %s)", port, operatorURL) log.Fatal(http.ListenAndServe(":"+port, nil)) } func handler(operatorURL, externalURL, serviceToken string, exclude map[string]bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var namespace, authHeader string // Вариант 1: /funcs/ — namespace прямо в URL, токен не нужен. // Namespace виден в URL каждой функции — не является секретом. if strings.HasPrefix(r.URL.Path, "/funcs/") { namespace = strings.TrimPrefix(r.URL.Path, "/funcs/") namespace = strings.Trim(namespace, "/") if namespace == "" { http.Error(w, "usage: /funcs/\n", http.StatusBadRequest) return } // Используем serviceToken сервиса, если задан if serviceToken != "" { authHeader = "Bearer " + serviceToken } // authHeader может остаться пустым — оператор ответит 401 если не настроен } else { // Вариант 2: ?token= — токен как query param (можно bookmarkнуть URL) token := r.URL.Query().Get("token") // Вариант 3: Authorization: Bearer — стандартный заголовок if token == "" { token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") } if token == "" { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusUnauthorized) fmt.Fprintf(w, "Укажите namespace в URL или передайте токен:\n\n") fmt.Fprintf(w, " %s/funcs/\n", externalURL) fmt.Fprintf(w, " %s/funcs?token=\n", externalURL) return } sub, err := subFromJWT(token) if err != nil { http.Error(w, fmt.Sprintf("invalid token: %s\n", err), http.StatusUnauthorized) return } namespace = namespaceFromSub(sub) authHeader = "Bearer " + token } fns, err := apiGet[[]fnResponse](operatorURL+"/v1/namespaces/"+namespace+"/functions", authHeader) if err != nil { http.Error(w, fmt.Sprintf("operator error (functions): %s\n", err), http.StatusBadGateway) return } trs, err := apiGet[[]trResponse](operatorURL+"/v1/namespaces/"+namespace+"/triggers", authHeader) if err != nil { http.Error(w, fmt.Sprintf("operator error (triggers): %s\n", err), http.StatusBadGateway) return } // Индекс триггеров по имени функции trigIdx := map[string][]trResponse{} for _, tr := range trs { trigIdx[tr.FunctionRef] = append(trigIdx[tr.FunctionRef], tr) } // Фильтрация и сортировка: активные вверх, потом по имени type entry struct { fn fnResponse httpT []trResponse cronT []trResponse isActive bool } var items []entry for _, fn := range fns { if exclude[fn.Name] { continue } var httpT, cronT []trResponse isActive := false for _, tr := range trigIdx[fn.Name] { switch tr.Type { case "http": httpT = append(httpT, tr) case "cron": cronT = append(cronT, tr) } if tr.Enabled && tr.Active { isActive = true } } items = append(items, entry{fn, httpT, cronT, isActive}) } sort.Slice(items, func(i, j int) bool { if items[i].isActive != items[j].isActive { return items[i].isActive } return items[i].fn.Name < items[j].fn.Name }) sep := strings.Repeat("─", 52) var sb strings.Builder for _, it := range items { fn := it.fn sb.WriteString(sep + "\n") sb.WriteString(" " + buildComment(fn, it.httpT, it.cronT) + "\n") sb.WriteString(fmt.Sprintf(" name: %s\n", fn.Name)) sb.WriteString(fmt.Sprintf(" runtime: %s\n", fn.Runtime)) sb.WriteString(fmt.Sprintf(" phase: %s\n", fn.Phase)) if it.isActive { sb.WriteString(" active: да\n") } else { sb.WriteString(" active: нет\n") } if len(it.httpT) > 0 { url := it.httpT[0].URL if externalURL != "" { url = externalURL + "/fn/" + namespace + "/" + fn.Name } sb.WriteString(fmt.Sprintf(" url: %s\n", url)) } if len(it.cronT) > 0 { sb.WriteString(fmt.Sprintf(" cron: %s\n", it.cronT[0].Schedule)) } if fn.CreatedAt != "" { sb.WriteString(fmt.Sprintf(" created: %s\n", fn.CreatedAt)) } if fn.LastBuiltAt != "" { sb.WriteString(fmt.Sprintf(" built: %s\n", fn.LastBuiltAt)) } if fn.Message != "" { sb.WriteString(fmt.Sprintf(" message: %s\n", fn.Message)) } } sb.WriteString(sep + "\n") sb.WriteString(fmt.Sprintf(" namespace: %s | total: %d\n", namespace, len(items))) sb.WriteString(sep + "\n") w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprint(w, sb.String()) } } func buildComment(fn fnResponse, httpT, cronT []trResponse) string { if len(httpT) > 0 { active := "активна" if !httpT[0].Active { active = "неактивна" } return fmt.Sprintf("HTTP endpoint (%s) — %s, %s", fn.Runtime, fn.Phase, active) } if len(cronT) > 0 { active := "активна" if !cronT[0].Active { active = "неактивна" } return fmt.Sprintf("Cron '%s' (%s) — %s, %s", cronT[0].Schedule, fn.Runtime, fn.Phase, active) } return fmt.Sprintf("Job/runner без триггера (%s) — %s", fn.Runtime, fn.Phase) } // subFromJWT декодирует JWT payload (без проверки подписи) и возвращает sub. // Подпись не проверяется — trusted perimeter: сервис работает за Ingress. func subFromJWT(token string) (string, error) { parts := strings.Split(token, ".") if len(parts) != 3 { return "", fmt.Errorf("invalid jwt: expected 3 parts") } // base64url без паддинга — добавляем паддинг стандартно payload := parts[1] switch len(payload) % 4 { case 2: payload += "==" case 3: payload += "=" } data, err := base64.URLEncoding.DecodeString(payload) if err != nil { return "", fmt.Errorf("decode payload: %w", err) } var claims map[string]any if err := json.Unmarshal(data, &claims); err != nil { return "", fmt.Errorf("unmarshal claims: %w", err) } sub, ok := claims["sub"].(string) if !ok || sub == "" { return "", fmt.Errorf("missing sub claim") } return sub, nil } // namespaceFromSub — та же логика что в операторе и terraform провайдере. // SHA256(sub) → первые 8 байт → hex → "sless-{16 hex символов}" func namespaceFromSub(sub string) string { hash := sha256.Sum256([]byte(sub)) return fmt.Sprintf("sless-%x", hash[:8]) } func apiGet[T any](url, authHeader string) (T, error) { var zero T req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return zero, err } req.Header.Set("Authorization", authHeader) resp, err := http.DefaultClient.Do(req) if err != nil { return zero, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return zero, fmt.Errorf("status %d: %s", resp.StatusCode, body) } if err := json.Unmarshal(body, &zero); err != nil { return zero, fmt.Errorf("unmarshal: %w", err) } return zero, nil } func env(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback }