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
This commit is contained in:
@@ -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
|
||||||
@@ -161,48 +161,4 @@ output "table_reader_url" {
|
|||||||
value = sless_trigger.postgres_table_reader_http.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/<namespace>/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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module sless/funcs-service
|
||||||
|
|
||||||
|
go 1.23
|
||||||
@@ -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 <token> 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user