package api // kwtriggers.go — CRUD хендлеры для KubernetesWatchTrigger (Fission KW Trigger). // // РЕШЕНИЕ ПО АРХИТЕКТУРЕ (2026-05-11): // KubernetesWatchTrigger позволяет вызывать функцию при изменении K8s объектов. // spec.type — тип ресурса: Pod, Service, Deployment, ConfigMap, и т.д. // spec.namespace — namespace для слежения (по умолчанию = namespace пользователя) // spec.labelselector — label selector в формате "key=value,key2=value2" // spec.functionref — ссылка на функцию // // ОСОБЕННОСТИ: // - Fission kubewatcher компонент должен быть задеплоен. // - namespace в spec — это WATCHED namespace (не namespace триггера). // Для безопасности ограничиваем: только namespace пользователя или пустое (тогда = userNS). // - labelselector опционален, "" = смотрим на все ресурсы типа resourceType в namespace. // // ОШИБКИ В ПРОЦЕССЕ: // - (нет, первая реализация) import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" "fission-console/internal/fission" "fission-console/internal/model" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) // validKWResourceTypes — поддерживаемые типы ресурсов для слежения. // Расширяемо — это не ограничение CRD, просто UI-валидация. var validKWResourceTypes = map[string]struct{}{ "pod": {}, "service": {}, "deployment": {}, "configmap": {}, "secret": {}, "namespace": {}, "replicaset": {}, "statefulset": {}, "daemonset": {}, "job": {}, } func (s *Server) handleKWTriggersRoot(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.handleList(fission.KWTrigGVR)(w, r) case http.MethodPost: s.handleCreateKWTrigger(w, r) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *Server) handleKWTriggersAction(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/console/api/kwtriggers/") path = strings.TrimPrefix(path, "/api/kwtriggers/") name := strings.Trim(path, "/") if name == "" || strings.Contains(name, "/") { http.NotFound(w, r) return } switch r.Method { case http.MethodGet: s.handleGetKWTrigger(w, r, name) case http.MethodDelete: s.handleDeleteKWTrigger(w, r, name) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *Server) handleCreateKWTrigger(w http.ResponseWriter, r *http.Request) { var req model.CreateKWTriggerRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err)) return } ns := s.userNS(r) // Если namespace не задан — используем namespace пользователя if strings.TrimSpace(req.Namespace) == "" { req.Namespace = ns } // Безопасность: нельзя смотреть за чужим namespace if req.Namespace != ns { writeJSONError(w, http.StatusForbidden, "can only watch your own namespace") return } if err := validateKWTriggerRequest(req); err != nil { writeJSONError(w, http.StatusBadRequest, err.Error()) return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() // Проверяем что функция существует if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, req.FunctionName, metav1.GetOptions{}); err != nil { if apierrors.IsNotFound(err) { writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("function %q not found", req.FunctionName)) return } writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function: %v", err)) return } obj := buildKWTriggerObject(ns, req) created, err := s.dyn.Resource(fission.KWTrigGVR).Namespace(ns).Create(ctx, obj, metav1.CreateOptions{}) if err != nil { if apierrors.IsAlreadyExists(err) { writeJSONError(w, http.StatusConflict, fmt.Sprintf("kwtrigger %q already exists", req.Name)) return } writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create kwtrigger: %v", err)) return } writeAnyJSON(w, http.StatusCreated, kwTriggerResponse(created)) } func (s *Server) handleGetKWTrigger(w http.ResponseWriter, r *http.Request, name string) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() obj, err := s.dyn.Resource(fission.KWTrigGVR).Namespace(s.userNS(r)).Get(ctx, name, metav1.GetOptions{}) if err != nil { status := http.StatusBadGateway if apierrors.IsNotFound(err) { status = http.StatusNotFound } writeJSONError(w, status, fmt.Sprintf("get kwtrigger %q: %v", name, err)) return } writeAnyJSON(w, http.StatusOK, kwTriggerResponse(obj)) } func (s *Server) handleDeleteKWTrigger(w http.ResponseWriter, r *http.Request, name string) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() if err := s.dyn.Resource(fission.KWTrigGVR).Namespace(s.userNS(r)).Delete(ctx, name, metav1.DeleteOptions{}); err != nil { status := http.StatusBadGateway if apierrors.IsNotFound(err) { status = http.StatusNotFound } writeJSONError(w, status, fmt.Sprintf("delete kwtrigger %q: %v", name, err)) return } writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name}) } // --- Вспомогательные функции --- func validateKWTriggerRequest(req model.CreateKWTriggerRequest) error { if strings.TrimSpace(req.Name) == "" { return fmt.Errorf("name is required") } if strings.TrimSpace(req.FunctionName) == "" { return fmt.Errorf("functionName is required") } rt := strings.ToLower(strings.TrimSpace(req.ResourceType)) if _, ok := validKWResourceTypes[rt]; !ok { return fmt.Errorf("resourceType must be one of: Pod, Service, Deployment, ConfigMap, Secret, Namespace, ReplicaSet, StatefulSet, DaemonSet, Job") } return nil } func buildKWTriggerObject(ns string, req model.CreateKWTriggerRequest) *unstructured.Unstructured { // Fission ожидает capitalize: Pod, Service, Deployment resourceType := capitalize(strings.TrimSpace(req.ResourceType)) spec := map[string]any{ "type": resourceType, "namespace": req.Namespace, "functionref": map[string]any{ "type": "name", "name": req.FunctionName, }, } if req.LabelSelector != "" { spec["labelselector"] = req.LabelSelector } return &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "fission.io/v1", "kind": "KubernetesWatchTrigger", "metadata": map[string]any{ "name": req.Name, "namespace": ns, }, "spec": spec, }} } func kwTriggerResponse(obj *unstructured.Unstructured) map[string]any { spec, _ := obj.Object["spec"].(map[string]any) if spec == nil { spec = map[string]any{} } fnref, _ := spec["functionref"].(map[string]any) fnName := "" if fnref != nil { fnName, _ = fnref["name"].(string) } return map[string]any{ "metadata": map[string]any{ "name": obj.GetName(), "namespace": obj.GetNamespace(), }, "spec": map[string]any{ "resourceType": spec["type"], "namespace": spec["namespace"], "labelSelector": spec["labelselector"], "functionName": fnName, }, } } // capitalize приводит первый символ к верхнему регистру, остальное без изменений. // "pod" → "Pod", "deployment" → "Deployment" func capitalize(s string) string { if s == "" { return s } return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) }