From e8cd62e1718fa2d6a4778fdc6a57bf1c2bfa69b6 Mon Sep 17 00:00:00 2001 From: Naeel Date: Wed, 18 Mar 2026 15:58:08 +0300 Subject: [PATCH] feat: funcs as global Go service (sless-funcs-service:v0.1.1) - services/funcs/main.go: standalone Go HTTP server extracts JWT sub -> SHA256[:8] -> namespace -> calls operator API returns plain text list, sorted active first, /health probe endpoint - services/funcs/Dockerfile: multi-stage Go build -> alpine - deployments/k8s/funcs-service.yaml: Deployment+Service+Ingress in sless ns ingress path /funcs -> sless-funcs-service:8090, reuses sless-operator-tls - examples/POSTGRES/resources.tf: removed funcs_list function+trigger+output Image: naeel/sless-funcs-service:v0.1.1 --- deployments/k8s/funcs-service.yaml | 105 +++++++++++ examples/POSTGRES/resources.tf | 44 ----- services/funcs/Dockerfile | 15 ++ services/funcs/go.mod | 3 + services/funcs/main.go | 275 +++++++++++++++++++++++++++++ 5 files changed, 398 insertions(+), 44 deletions(-) create mode 100644 deployments/k8s/funcs-service.yaml create mode 100644 services/funcs/Dockerfile create mode 100644 services/funcs/go.mod create mode 100644 services/funcs/main.go diff --git a/deployments/k8s/funcs-service.yaml b/deployments/k8s/funcs-service.yaml new file mode 100644 index 0000000..947fb4b --- /dev/null +++ b/deployments/k8s/funcs-service.yaml @@ -0,0 +1,105 @@ +# 2026-03-18 +# funcs-service.yaml — глобальный сервис листинга функций для всех пользователей. +# Развёртывается ОДИН РАЗ в namespace sless рядом с оператором. +# Доступен по: https://sless.kube5s.ru/funcs (с Bearer токеном пользователя) +# +# Обновить образ и применить: +# docker push naeel/sless-funcs-service:v0.1.0 +# kubectl apply -f deployments/k8s/funcs-service.yaml + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sless-funcs-service + namespace: sless + labels: + app: sless-funcs-service +spec: + replicas: 1 + selector: + matchLabels: + app: sless-funcs-service + template: + metadata: + labels: + app: sless-funcs-service + spec: + containers: + - name: funcs + image: naeel/sless-funcs-service:v0.1.1 + ports: + - containerPort: 8090 + env: + - name: SLESS_OPERATOR_URL + value: "http://sless-operator.sless.svc.cluster.local:9090" + - name: SLESS_EXTERNAL_URL + value: "https://sless.kube5s.ru" + # Системные функции, скрытые из листинга + - name: SLESS_EXCLUDE + value: "event-writer,event-monitor,event-cleaner" + - name: PORT + value: "8090" + livenessProbe: + httpGet: + path: /health + port: 8090 + initialDelaySeconds: 5 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /health + port: 8090 + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 100m + memory: 64Mi + +--- +apiVersion: v1 +kind: Service +metadata: + name: sless-funcs-service + namespace: sless +spec: + selector: + app: sless-funcs-service + ports: + - port: 8090 + targetPort: 8090 + +--- +# Отдельный Ingress для /funcs — nginx выбирает более специфичный путь перед / +# Без rewrite: сервис сам обрабатывает /funcs path +# TLS-сертификат sless-operator-tls уже управляется cert-manager через ingress оператора; +# здесь только ссылаемся на существующий секрет без аннотации cert-manager.io/cluster-issuer. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: sless-funcs-ingress + namespace: sless + annotations: + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" + nginx.ingress.kubernetes.io/ssl-redirect: "true" +spec: + ingressClassName: nginx + rules: + - host: sless.kube5s.ru + http: + paths: + - path: /funcs + pathType: Prefix + backend: + service: + name: sless-funcs-service + port: + number: 8090 + tls: + - hosts: + - sless.kube5s.ru + secretName: sless-operator-tls diff --git a/examples/POSTGRES/resources.tf b/examples/POSTGRES/resources.tf index 3605ef0..e212e0d 100644 --- a/examples/POSTGRES/resources.tf +++ b/examples/POSTGRES/resources.tf @@ -161,48 +161,4 @@ output "table_reader_url" { value = sless_trigger.postgres_table_reader_http.url } -# Namespace пользователя — извлекаем из уже известного URL триггера. -# URL формат: https://sless.kube5s.ru/fn/sless-{hex16}/{name} -# split("/") → ["https:", "", "sless.kube5s.ru", "fn", "sless-{hex16}", "{name}"] -# element(..., 4) → "sless-{hex16}" -locals { - user_namespace = element(split("/", sless_trigger.postgres_table_reader_http.url), 4) -} - -# HTTP-функция возвращает список всех функций пользователя с их статусами и триггерами. -# Вызывает внутренний API оператора — не проходит через внешний Ingress. -# Доступна по URL: https://sless.kube5s.ru/fn//funcs -resource "sless_function" "funcs_list" { - name = "funcs" - runtime = "python3.11" - entrypoint = "funcs_list.list_all" - memory_mb = 128 - timeout_sec = 15 - - env_vars = { - # Внутренний ClusterIP сервис оператора — без TLS, без DNS-overhead - SLESS_API_URL = "http://sless-operator.sless.svc.cluster.local:9090" - SLESS_NAMESPACE = local.user_namespace - SLESS_TOKEN = var.api_token - # Публичный домен: terraform state может хранить старый sless-api.kube5s.ru в URLs - SLESS_EXTERNAL_URL = "https://sless.kube5s.ru" - # Служебные функции — не показывать в листинге - SLESS_EXCLUDE = "funcs,event-writer,event-monitor,event-cleaner" - } - - source_dir = "${path.module}/code/funcs-list" - - depends_on = [sless_trigger.postgres_table_reader_http] -} - -resource "sless_trigger" "funcs_list_http" { - name = "funcs-http" - type = "http" - function = sless_function.funcs_list.name - enabled = true -} - -output "funcs_url" { - value = sless_trigger.funcs_list_http.url -} diff --git a/services/funcs/Dockerfile b/services/funcs/Dockerfile new file mode 100644 index 0000000..8633a2e --- /dev/null +++ b/services/funcs/Dockerfile @@ -0,0 +1,15 @@ +# 2026-03-18 +# Dockerfile для sless-funcs-service. +# Многоэтапная сборка: Go → alpine (минимальный образ). +# Сервис не нуждается во внешних зависимостях — только stdlib. + +FROM golang:1.23-alpine AS builder +WORKDIR /build +COPY go.mod main.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o funcs-service . + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates +COPY --from=builder /build/funcs-service /funcs-service +EXPOSE 8090 +ENTRYPOINT ["/funcs-service"] diff --git a/services/funcs/go.mod b/services/funcs/go.mod new file mode 100644 index 0000000..cf08549 --- /dev/null +++ b/services/funcs/go.mod @@ -0,0 +1,3 @@ +module sless/funcs-service + +go 1.23 diff --git a/services/funcs/main.go b/services/funcs/main.go new file mode 100644 index 0000000..9816fbb --- /dev/null +++ b/services/funcs/main.go @@ -0,0 +1,275 @@ +// 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") + + 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, 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") + }) + + log.Printf("sless-funcs-service listening on :%s (operator: %s)", port, operatorURL) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} + +func handler(operatorURL, externalURL string, exclude map[string]bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/funcs" { + http.NotFound(w, r) + return + } + + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if token == "" { + http.Error(w, "Authorization: Bearer required\n", http.StatusUnauthorized) + 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 +}