Files
sless/services/funcs/main.go
T
Naeel 38bb494ed5 feat: funcs-service v0.1.3 — /funcs/<namespace> URL without token
- /funcs/<namespace>: user opens URL in browser, no auth needed
  service uses SLESS_SERVICE_TOKEN to query operator internally
- /funcs?token=<jwt>: token as query param (bookmarkable URL)
- /funcs: returns usage hint with both URL formats
- SLESS_SERVICE_TOKEN set via kubectl set env (not stored in repo)
2026-03-18 16:11:17 +03:00

301 lines
10 KiB
Go

// 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/<namespace> (без токена пользователя)
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> — без токена, 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> — 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/<namespace>\n", http.StatusBadRequest)
return
}
// Используем serviceToken сервиса, если задан
if serviceToken != "" {
authHeader = "Bearer " + serviceToken
}
// authHeader может остаться пустым — оператор ответит 401 если не настроен
} else {
// Вариант 2: ?token=<jwt> — токен как query param (можно bookmarkнуть URL)
token := r.URL.Query().Get("token")
// Вариант 3: Authorization: Bearer <jwt> — стандартный заголовок
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/<namespace>\n", externalURL)
fmt.Fprintf(w, " %s/funcs?token=<jwt>\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
}