// Изменено: 2026-03-11 // functions.go — CRUD handlers для Function CRD. // Принимает JSON, создаёт/обновляет/удаляет k8s ресурсы Function. // Namespace берётся из URL: /v1/namespaces/{namespace}/functions/{name} package handler import ( "encoding/json" "net/http" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1" ) // functionRequest — тело запроса для создания/обновления функции. type functionRequest struct { Name string `json:"name"` Runtime string `json:"runtime"` Entrypoint string `json:"entrypoint"` MemoryMB int32 `json:"memory_mb"` TimeoutSec int32 `json:"timeout_sec"` Env map[string]string `json:"env_vars"` S3Bucket string `json:"s3_bucket"` S3Key string `json:"s3_key"` } // functionResponse — ответ при чтении функции. type functionResponse struct { Name string `json:"name"` Namespace string `json:"namespace"` Runtime string `json:"runtime"` Entrypoint string `json:"entrypoint"` MemoryMB int32 `json:"memory_mb"` TimeoutSec int32 `json:"timeout_sec"` Env map[string]string `json:"env_vars"` S3Bucket string `json:"s3_bucket"` S3Key string `json:"s3_key"` Phase slessv1alpha1.FunctionPhase `json:"phase"` ImageRef string `json:"image_ref"` Message string `json:"message,omitempty"` } // fnToResponse конвертирует CRD в ответ API. func fnToResponse(fn *slessv1alpha1.Function) functionResponse { return functionResponse{ Name: fn.Name, Namespace: fn.Namespace, Runtime: fn.Spec.Runtime, Entrypoint: fn.Spec.Entrypoint, MemoryMB: fn.Spec.MemoryMB, TimeoutSec: fn.Spec.TimeoutSec, Env: fn.Spec.Env, S3Bucket: fn.Spec.S3Bucket, S3Key: fn.Spec.S3Key, Phase: fn.Status.Phase, ImageRef: fn.Status.ImageRef, Message: fn.Status.Message, } } // ListFunctions — GET /v1/namespaces/{namespace}/functions func (h *Handler) ListFunctions(w http.ResponseWriter, r *http.Request) { ns := namespace(r) list := &slessv1alpha1.FunctionList{} if err := h.K8s.List(r.Context(), list, client.InNamespace(ns)); err != nil { writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } result := make([]functionResponse, 0, len(list.Items)) for i := range list.Items { result = append(result, fnToResponse(&list.Items[i])) } writeJSON(w, http.StatusOK, result) } // CreateFunction — POST /v1/namespaces/{namespace}/functions func (h *Handler) CreateFunction(w http.ResponseWriter, r *http.Request) { ns := namespace(r) var req functionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) return } if req.Name == "" || req.Runtime == "" { writeJSON(w, http.StatusBadRequest, errResp("name and runtime are required")) return } if req.Entrypoint == "" { writeJSON(w, http.StatusBadRequest, errResp("entrypoint is required")) return } if req.MemoryMB <= 0 || req.MemoryMB > 4096 { writeJSON(w, http.StatusBadRequest, errResp("memory_mb must be between 1 and 4096")) return } fn := &slessv1alpha1.Function{ ObjectMeta: metav1.ObjectMeta{ Name: req.Name, Namespace: ns, }, Spec: slessv1alpha1.FunctionSpec{ Runtime: req.Runtime, Entrypoint: req.Entrypoint, MemoryMB: req.MemoryMB, TimeoutSec: req.TimeoutSec, Env: req.Env, S3Bucket: req.S3Bucket, S3Key: req.S3Key, }, } if err := h.K8s.Create(r.Context(), fn); err != nil { if errors.IsAlreadyExists(err) { // Если существующая функция в статусе Failed (build провалился, terraform не // добавил её в state) — удаляем её и пересоздаём, иначе клиент получит 409 навсегда. existing := &slessv1alpha1.Function{} if getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing); getErr == nil && existing.Status.Phase == slessv1alpha1.FunctionPhaseFailed { _ = h.K8s.Delete(r.Context(), existing) // Создаём заново с теми же параметрами fn.ResourceVersion = "" if createErr := h.K8s.Create(r.Context(), fn); createErr != nil { writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error())) return } writeJSON(w, http.StatusCreated, fnToResponse(fn)) return } writeJSON(w, http.StatusConflict, errResp("function already exists")) return } writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } writeJSON(w, http.StatusCreated, fnToResponse(fn)) } // GetFunction — GET /v1/namespaces/{namespace}/functions/{name} func (h *Handler) GetFunction(w http.ResponseWriter, r *http.Request) { ns := namespace(r) name := pathVar(r, "name") fn := &slessv1alpha1.Function{} if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fn); err != nil { if errors.IsNotFound(err) { writeJSON(w, http.StatusNotFound, errResp("function not found")) return } writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } writeJSON(w, http.StatusOK, fnToResponse(fn)) } // UpdateFunction — PUT /v1/namespaces/{namespace}/functions/{name} func (h *Handler) UpdateFunction(w http.ResponseWriter, r *http.Request) { ns := namespace(r) name := pathVar(r, "name") var req functionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) return } fn := &slessv1alpha1.Function{} if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fn); err != nil { if errors.IsNotFound(err) { writeJSON(w, http.StatusNotFound, errResp("function not found")) return } writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } // Валидация обязательных полей — PUT семантика, нельзя обнулять критичные поля if req.Runtime == "" || req.Entrypoint == "" { writeJSON(w, http.StatusBadRequest, errResp("runtime and entrypoint are required")) return } if req.MemoryMB <= 0 || req.MemoryMB > 4096 { writeJSON(w, http.StatusBadRequest, errResp("memory_mb must be between 1 and 4096")) return } // Обновляем только изменяемые поля spec fn.Spec.Runtime = req.Runtime fn.Spec.Entrypoint = req.Entrypoint fn.Spec.MemoryMB = req.MemoryMB fn.Spec.TimeoutSec = req.TimeoutSec fn.Spec.Env = req.Env if req.S3Bucket != "" { fn.Spec.S3Bucket = req.S3Bucket } if req.S3Key != "" { fn.Spec.S3Key = req.S3Key } if err := h.K8s.Update(r.Context(), fn); err != nil { writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } writeJSON(w, http.StatusOK, fnToResponse(fn)) } // DeleteFunction — DELETE /v1/namespaces/{namespace}/functions/{name} func (h *Handler) DeleteFunction(w http.ResponseWriter, r *http.Request) { ns := namespace(r) name := pathVar(r, "name") fn := &slessv1alpha1.Function{} if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fn); err != nil { if errors.IsNotFound(err) { w.WriteHeader(http.StatusNoContent) return } writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } if err := h.K8s.Delete(r.Context(), fn); err != nil { writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } w.WriteHeader(http.StatusNoContent) }