feat: proxy /fn/{namespace}/{name} — обход wildcard DNS

Проблема: wildcard DNS *.fn.kube5s.ru недоступен.
Решение: прокси через sless-api.kube5s.ru/fn/{ns}/{name}.

- handler/invoke.go: прокси к Service функции внутри кластера
- router.go: /fn/ без auth токена, /v1/ с auth (gorilla Use())
- config.go: поле ExternalURL (EXTERNAL_URL env)
- trigger_controller.go: если ExternalURL задан — URL = ExternalURL/fn/{ns}/{fn}
  иначе fallback: Ingress + поддомен (прежнее поведение)
- operator.yaml: EXTERNAL_URL=https://sless-api.kube5s.ru, image v0.1.5

Оператор v0.1.5 задеплоен.
E2E: curl https://sless-api.kube5s.ru/fn/default/hello-node → {"message":"Hello, Naeel! (nodejs20)"}
This commit is contained in:
“Naeel”
2026-03-07 18:36:03 +04:00
parent a6bc67de05
commit 2ee9cae6d2
6 changed files with 313 additions and 220 deletions
+19 -10
View File
@@ -26,11 +26,12 @@ slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1
type TriggerReconciler struct { type TriggerReconciler struct {
client.Client client.Client
Scheme *runtime.Scheme Scheme *runtime.Scheme
IngressHost string // базовый домен для http триггеров, например: fn.kube5s.ru IngressHost string // базовый домен для http триггеров (fallback): fn.kube5s.ru
// ExternalURL — если задан, URL функции = ExternalURL/fn/{namespace}/{name}
// (обходит wildcard DNS, трафик идёт через sless-api.kube5s.ru)
ExternalURL string
} }
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=triggers,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=triggers/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=triggers/finalizers,verbs=update //+kubebuilder:rbac:groups=sless.kube5s.ru,resources=triggers/finalizers,verbs=update
//+kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete
@@ -95,14 +96,13 @@ return r.reconcileCron(ctx, tr, fn)
return ctrl.Result{}, nil return ctrl.Result{}, nil
} }
// reconcileHTTP создаёт Service и Ingress для HTTP триггера. // reconcileHTTP создаёт Service для HTTP триггера.
// URL функции: https://{funcName}-{namespace}.{IngressHost} // Если ExternalURL задан — URL = {ExternalURL}/fn/{namespace}/{name} (прокси через API).
// Иначе (fallback) — URL = https://{funcName}-{namespace}.{IngressHost} + создаёт Ingress.
func (r *TriggerReconciler) reconcileHTTP(ctx context.Context, tr *slessv1alpha1.Trigger, fn *slessv1alpha1.Function) (ctrl.Result, error) { func (r *TriggerReconciler) reconcileHTTP(ctx context.Context, tr *slessv1alpha1.Trigger, fn *slessv1alpha1.Function) (ctrl.Result, error) {
deployNS := "sless-fn-" + tr.Namespace deployNS := "sless-fn-" + tr.Namespace
host := fmt.Sprintf("%s-%s.%s", fn.Name, tr.Namespace, r.IngressHost)
pathType := netv1.PathTypePrefix
// Service — направляет трафик к Deployment функции // Service — направляет трафик к Deployment функции (нужен и для прокси, и для Ingress)
wantSvc := &corev1.Service{ wantSvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: fn.Name, Namespace: deployNS}, ObjectMeta: metav1.ObjectMeta{Name: fn.Name, Namespace: deployNS},
Spec: corev1.ServiceSpec{ Spec: corev1.ServiceSpec{
@@ -121,7 +121,14 @@ return ctrl.Result{}, fmt.Errorf("get service: %w", err)
} }
} }
// Ingress — внешний доступ по host var funcURL string
if r.ExternalURL != "" {
// Режим прокси через API сервер — не нужен wildcard DNS
funcURL = fmt.Sprintf("%s/fn/%s/%s", r.ExternalURL, tr.Namespace, fn.Name)
} else {
// Fallback: Ingress с поддоменом (требует wildcard DNS *.IngressHost)
host := fmt.Sprintf("%s-%s.%s", fn.Name, tr.Namespace, r.IngressHost)
pathType := netv1.PathTypePrefix
wantIng := &netv1.Ingress{ wantIng := &netv1.Ingress{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: fn.Name, Name: fn.Name,
@@ -164,9 +171,11 @@ return ctrl.Result{}, fmt.Errorf("create ingress: %w", err)
return ctrl.Result{}, fmt.Errorf("get ingress: %w", err) return ctrl.Result{}, fmt.Errorf("get ingress: %w", err)
} }
} }
funcURL = "https://" + host
}
tr.Status.Active = true tr.Status.Active = true
tr.Status.URL = "https://" + host tr.Status.URL = funcURL
tr.Status.Message = "" tr.Status.Message = ""
if err := r.Status().Update(ctx, tr); err != nil { if err := r.Status().Update(ctx, tr); err != nil {
return ctrl.Result{}, fmt.Errorf("update trigger status: %w", err) return ctrl.Result{}, fmt.Errorf("update trigger status: %w", err)
+4 -1
View File
@@ -31,6 +31,9 @@ data:
REGISTRY_SECRET: "sless-registry-auth" REGISTRY_SECRET: "sless-registry-auth"
API_PORT: "9090" API_PORT: "9090"
INGRESS_HOST: "fn.kube5s.ru" INGRESS_HOST: "fn.kube5s.ru"
# EXTERNAL_URL — если задан, URL функции = EXTERNAL_URL/fn/{namespace}/{name}
# Позволяет обойтись без wildcard DNS *.fn.kube5s.ru
EXTERNAL_URL: "https://sless-api.kube5s.ru"
--- ---
# Secret создаётся отдельно через kubectl (не коммитить секреты в git!) # Secret создаётся отдельно через kubectl (не коммитить секреты в git!)
# Описание ключей: # Описание ключей:
@@ -67,7 +70,7 @@ spec:
containers: containers:
- name: operator - name: operator
# При обновлении версии оператора — менять тег здесь (не latest!) # При обновлении версии оператора — менять тег здесь (не latest!)
image: naeel/sless-operator:v0.1.4 image: naeel/sless-operator:v0.1.5
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый) # Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
+65
View File
@@ -0,0 +1,65 @@
// Изменено: 2026-03-07
// invoke.go — прокси-обработчик для вызова HTTP-триггеров функций.
// Маршрут: ANY /fn/{namespace}/{name}
// Не защищён auth-токеном — это публичный эндпоинт для вызова функций.
// Проксирует запрос к ClusterIP Service функции внутри кластера:
// http://{name}.sless-fn-{namespace}.svc.cluster.local:8080
// Функция должна быть задеплоена (HTTP триггер активен).
package handler
import (
"fmt"
"io"
"net/http"
"time"
"github.com/gorilla/mux"
)
// httpClient используется для обращения к функциям внутри кластера.
// Таймаут 30s — достаточно для холодного старта функции.
var httpClient = &http.Client{Timeout: 30 * time.Second}
// InvokeFunction проксирует входящий запрос к Service функции в кластере.
// Namespace выбирается из пути, имя функции — тоже из пути.
// Сохраняет метод, тело, и Content-Type заголовок оригинального запроса.
func (h *Handler) InvokeFunction(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
ns := vars["namespace"]
name := vars["name"]
// Внутренний URL к Service функции (DNS внутри кластера)
// Namespace функций: sless-fn-{user-namespace}
target := fmt.Sprintf("http://%s.sless-fn-%s.svc.cluster.local:8080", name, ns)
// Создаём проксируемый запрос с тем же методом и телом
proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body)
if err != nil {
h.Log.Error("invoke: failed to create proxy request", "err", err, "ns", ns, "fn", name)
writeJSON(w, http.StatusInternalServerError, errResp("failed to create proxy request"))
return
}
// Пробрасываем Content-Type если есть
if ct := r.Header.Get("Content-Type"); ct != "" {
proxyReq.Header.Set("Content-Type", ct)
}
resp, err := httpClient.Do(proxyReq)
if err != nil {
h.Log.Error("invoke: function unreachable", "err", err, "ns", ns, "fn", name, "target", target)
writeJSON(w, http.StatusBadGateway, errResp("function unreachable: "+err.Error()))
return
}
defer resp.Body.Close()
// Копируем заголовки и статус из ответа функции
for k, vals := range resp.Header {
for _, v := range vals {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
+13 -6
View File
@@ -17,9 +17,14 @@ import (
// NewRouter собирает gorilla/mux роутер со всеми маршрутами. // NewRouter собирает gorilla/mux роутер со всеми маршрутами.
// apiToken — статический Bearer-токен для v1 аутентификации. // apiToken — статический Bearer-токен для v1 аутентификации.
// /fn/{namespace}/{name} — публичный прокси для вызова функций, без auth.
func NewRouter(h *handler.Handler, apiToken string, log *slog.Logger) http.Handler { func NewRouter(h *handler.Handler, apiToken string, log *slog.Logger) http.Handler {
r := mux.NewRouter() r := mux.NewRouter()
// Публичный прокси для вызова HTTP-триггеров — без auth токена
// Все HTTP методы разрешены (GET/POST/PUT/... — решает сама функция)
r.PathPrefix("/fn/{namespace}/{name}").HandlerFunc(h.InvokeFunction)
// Суброутер для /v1 — все маршруты API // Суброутер для /v1 — все маршруты API
v1 := r.PathPrefix("/v1").Subrouter() v1 := r.PathPrefix("/v1").Subrouter()
@@ -47,10 +52,12 @@ func NewRouter(h *handler.Handler, apiToken string, log *slog.Logger) http.Handl
v1.HandleFunc("/namespaces/{namespace}/jobs/{name}", h.GetJob).Methods(http.MethodGet) v1.HandleFunc("/namespaces/{namespace}/jobs/{name}", h.GetJob).Methods(http.MethodGet)
v1.HandleFunc("/namespaces/{namespace}/jobs/{name}", h.DeleteJob).Methods(http.MethodDelete) v1.HandleFunc("/namespaces/{namespace}/jobs/{name}", h.DeleteJob).Methods(http.MethodDelete)
// Цепочка middleware: logging → auth → router // Цепочка middleware: logging → (auth только для /v1/) → router
// Порядок важен: сначала логируем (чтобы видеть все запросы включая отклонённые), // /fn/ — без auth, /v1/ — с auth.
// затем проверяем авторизацию. // Используем gorilla/mux Use() чтобы auth применялся только к v1 суброутеру.
return middleware.Logging(log, v1.Use(func(next http.Handler) http.Handler {
middleware.Auth(apiToken, log, r), return middleware.Auth(apiToken, log, next)
) })
return middleware.Logging(log, r)
} }
+11 -3
View File
@@ -41,10 +41,15 @@ type Config struct {
// BuilderImage — образ для сборки функций (kaniko или buildah) // BuilderImage — образ для сборки функций (kaniko или buildah)
BuilderImage string BuilderImage string
// IngressHost — базовый домен для HTTP триггеров, например: fn.kube5s.ru // IngressHost — базовый домен для HTTP триггеров (используется только
// URL функции будет: https://{funcName}-{namespace}.{IngressHost} // если ExternalURL не задан, для обратной совместимости).
IngressHost string IngressHost string
// ExternalURL — публичный URL API сервера, например: https://sless-api.kube5s.ru
// Если задан, URL функции формируется как: {ExternalURL}/fn/{namespace}/{name}
// Это позволяет обойтись без wildcard DNS *.fn.
ExternalURL string
// APIToken — статический Bearer-токен для v1 REST API аутентификации. // APIToken — статический Bearer-токен для v1 REST API аутентификации.
// В prod заменить на вызов auth-сервиса. // В prod заменить на вызов auth-сервиса.
APIToken string APIToken string
@@ -117,12 +122,15 @@ func Load() (*Config, error) {
cfg.BuilderImage = v cfg.BuilderImage = v
} }
// IngressHost — базовый домен для HTTP триггеров // IngressHost — базовый домен для HTTP триггеров (fallback)
cfg.IngressHost = os.Getenv("INGRESS_HOST") cfg.IngressHost = os.Getenv("INGRESS_HOST")
if cfg.IngressHost == "" { if cfg.IngressHost == "" {
cfg.IngressHost = "fn.kube5s.ru" cfg.IngressHost = "fn.kube5s.ru"
} }
// ExternalURL — публичный URL сервиса для формирования URL функций
cfg.ExternalURL = os.Getenv("EXTERNAL_URL")
// APIToken — обязательный токен для REST API // APIToken — обязательный токен для REST API
cfg.APIToken = os.Getenv("SLESS_API_TOKEN") cfg.APIToken = os.Getenv("SLESS_API_TOKEN")
if cfg.APIToken == "" { if cfg.APIToken == "" {
+1
View File
@@ -140,6 +140,7 @@ func main() {
Client: mgr.GetClient(), Client: mgr.GetClient(),
Scheme: mgr.GetScheme(), Scheme: mgr.GetScheme(),
IngressHost: cfg.IngressHost, IngressHost: cfg.IngressHost,
ExternalURL: cfg.ExternalURL,
}).SetupWithManager(mgr); err != nil { }).SetupWithManager(mgr); err != nil {
log.Error("unable to create controller", "controller", "Trigger", "err", err) log.Error("unable to create controller", "controller", "Trigger", "err", err)
os.Exit(1) os.Exit(1)