// Создано: 2026-03-20 (function-service-split) // Изменено: 2026-03-21 (fix: DeleteService возвращает 404 вместо 204 при отсутствующем объекте) // Изменено: 2026-03-22 (fix: CreateService 409 при пересоздании сервиса через terraform -replace) // services.go — CRUD handlers для Service CRD (sless_service). // sless_service = long-running Deployment + URL. Каждый вызов проксируется к поду. // Namespace берётся из URL: /v1/namespaces/{namespace}/services/{name} package handler import ( "crypto/sha256" "encoding/json" "fmt" "io" "net/http" "time" "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" "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder" ) // serviceRequest — тело запроса для создания/обновления сервиса. type serviceRequest 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"` } // serviceResponse — ответ при чтении сервиса. // URL — ключевое отличие от functionResponse: заполняется оператором сразу после деплоя. type serviceResponse 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.ServicePhase `json:"phase"` ImageRef string `json:"image_ref"` URL string `json:"url,omitempty"` Message string `json:"message,omitempty"` CreatedAt string `json:"created_at,omitempty"` LastBuiltAt string `json:"last_built_at,omitempty"` } // svcToResponse конвертирует Service CRD в ответ API. func svcToResponse(svc *slessv1alpha1.Service) serviceResponse { resp := serviceResponse{ Name: svc.Name, Namespace: svc.Namespace, Runtime: svc.Spec.Runtime, Entrypoint: svc.Spec.Entrypoint, MemoryMB: svc.Spec.MemoryMB, TimeoutSec: svc.Spec.TimeoutSec, Env: svc.Spec.Env, S3Bucket: svc.Spec.S3Bucket, S3Key: svc.Spec.S3Key, Phase: svc.Status.Phase, ImageRef: svc.Status.ImageRef, URL: svc.Status.URL, Message: svc.Status.Message, } if !svc.CreationTimestamp.IsZero() { resp.CreatedAt = svc.CreationTimestamp.UTC().Format("2006-01-02 15:04:05 UTC") } if svc.Status.LastBuiltAt != nil && !svc.Status.LastBuiltAt.IsZero() { resp.LastBuiltAt = svc.Status.LastBuiltAt.UTC().Format("2006-01-02 15:04:05 UTC") } return resp } // ListServices — GET /v1/namespaces/{namespace}/services func (h *Handler) ListServices(w http.ResponseWriter, r *http.Request) { ns := namespace(r) list := &slessv1alpha1.ServiceList{} if err := h.K8s.List(r.Context(), list, client.InNamespace(ns)); err != nil { writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } result := make([]serviceResponse, 0, len(list.Items)) for i := range list.Items { result = append(result, svcToResponse(&list.Items[i])) } writeJSON(w, http.StatusOK, result) } // CreateService — POST /v1/namespaces/{namespace}/services func (h *Handler) CreateService(w http.ResponseWriter, r *http.Request) { ns := namespace(r) var req serviceRequest 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 } // timeout_sec: 0 = нет ограничения (допустимо), отрицательное — ошибка. if req.TimeoutSec < 0 || req.TimeoutSec > 900 { writeJSON(w, http.StatusBadRequest, errResp("timeout_sec must be 0 (no limit) or 1–900")) return } svc := &slessv1alpha1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: req.Name, Namespace: ns, }, Spec: slessv1alpha1.ServiceSpec{ Runtime: req.Runtime, Entrypoint: req.Entrypoint, MemoryMB: req.MemoryMB, TimeoutSec: req.TimeoutSec, Env: req.Env, S3Bucket: req.S3Bucket, S3Key: req.S3Key, }, } // Пытаемся создать Service CRD в k8s. // h.K8s.Create обращается к etcd через controller-runtime client. if err := h.K8s.Create(r.Context(), svc); err != nil { // IsAlreadyExists = etcd вернул 409 CONFLICT. // Это нормально при конкурентных запросах или при "replace" через terraform. // Сценарии: // // [A] terraform apply -replace: // 1. terraform DELETE /services/{name} → API вызывает h.K8s.Delete(svc) // 2. k8s НЕ удаляет объект немедленно: ставит DeletionTimestamp и ждёт // пока service_controller.go снимет finalizer sless.kube5s.ru/service-finalizer // (контроллер сначала сносит Deployment/Service/Ingress) // 3. terraform сразу POST /services/{name} → h.K8s.Create → IsAlreadyExists // (объект ещё есть в etcd, просто помечен на удаление) // → СТАРЫЙ КОД: видел phase=Ready, shouldRecreate=false → возвращал 409 ← БАГ // → НОВЫЙ КОД: видит DeletionTimestamp → ждёт исчезновения → создаёт // // [B] Кеш controller-runtime (split-brain): // Объект удалён из etcd, но informer-кеш ещё не обновился. // h.K8s.Create падает с IsAlreadyExists из кеша, хотя в etcd объекта нет. // → Get возвращает NotFound → пересоздаём. // // [C] Сервис в фазе Failed: // Предыдущий build провалился, terraform не добавил в state. // Новый apply пытается создать снова → Failed объект удаляем и пересоздаём. if errors.IsAlreadyExists(err) { // Получаем актуальное состояние объекта напрямую из etcd (не из кеша). // Нужно чтобы точно определить сценарий A/B/C. existing := &slessv1alpha1.Service{} getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing) // --- Сценарий A: объект помечен на удаление --- // DeletionTimestamp ≠ zero означает что k8s принял DELETE и ждёт finalizer. // Finalizer снимает service_controller.go асинхронно (обычно 1-5 сек). // Мы не можем создать объект пока старый существует — ждём его исчезновения. if getErr == nil && !existing.DeletionTimestamp.IsZero() { deleted := false // Проверяем каждую секунду до 30 итераций (= 30 секунд максимум). // Обычно finalizer снимается за 1-3 секунды, 30 — запас на перегруженный кластер. for i := 0; i < 30; i++ { time.Sleep(1 * time.Second) checkErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing) // IsNotFound = объект полностью исчез из etcd — можно создавать. if errors.IsNotFound(checkErr) { deleted = true break } // Любая другая ошибка Get — продолжаем ждать (может быть временный сбой API). } // 30 секунд прошло, объект всё ещё не удалён. // Вероятно контроллер завис или finalizer не снимается. // Возвращаем 409 с понятным сообщением — клиент (terraform) должен retry. if !deleted { writeJSON(w, http.StatusConflict, errResp("service is being deleted, try again later")) return } // Объект исчез. Сбрасываем ResourceVersion чтобы k8s воспринял как новый объект. // ResourceVersion заполняется при первом Get — при Create должен быть пустым. svc.ResourceVersion = "" if createErr := h.K8s.Create(r.Context(), svc); createErr != nil { writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error())) return } writeJSON(w, http.StatusCreated, svcToResponse(svc)) return } // --- Сценарии B и C: split-brain кеша или объект в фазе Failed --- // shouldRecreate=true если: // - getErr = NotFound (кеш врёт — объекта в etcd нет) // - объект есть, но в фазе Failed (предыдущий build провалился) shouldRecreate := errors.IsNotFound(getErr) || (getErr == nil && existing.Status.Phase == slessv1alpha1.ServicePhaseFailed) if shouldRecreate { // Если объект реально существует (Failed) — удаляем его перед пересозданием. // Ошибку удаления игнорируем: даже если Delete упал, // следующий Create либо пройдёт (объект исчез) либо снова упадёт с понятной ошибкой. if getErr == nil { _ = h.K8s.Delete(r.Context(), existing) } // Сбрасываем ResourceVersion — при split-brain etcd считает объект новым. svc.ResourceVersion = "" if createErr := h.K8s.Create(r.Context(), svc); createErr != nil { writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error())) return } writeJSON(w, http.StatusCreated, svcToResponse(svc)) return } // Объект существует, DeletionTimestamp=zero, фаза не Failed. // Это легитимный конфликт — сервис реально работает, клиент пытается создать дубликат. writeJSON(w, http.StatusConflict, errResp("service already exists")) return } // k8s возвращает StatusInvalid (422) при нарушении enum-валидации CRD. // Например: runtime="java8" которого нет в openapi enum сервисного CRD. // Маппим в 400 (Bad Request) а не в 500 — это ошибка клиента, не сервера. if errors.IsInvalid(err) { writeJSON(w, http.StatusBadRequest, errResp("invalid service spec: "+err.Error())) return } writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } writeJSON(w, http.StatusCreated, svcToResponse(svc)) } // GetService — GET /v1/namespaces/{namespace}/services/{name} func (h *Handler) GetService(w http.ResponseWriter, r *http.Request) { ns := namespace(r) name := pathVar(r, "name") svc := &slessv1alpha1.Service{} if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc); err != nil { if errors.IsNotFound(err) { writeJSON(w, http.StatusNotFound, errResp("service not found")) return } writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } writeJSON(w, http.StatusOK, svcToResponse(svc)) } // UpdateService — PUT /v1/namespaces/{namespace}/services/{name} func (h *Handler) UpdateService(w http.ResponseWriter, r *http.Request) { ns := namespace(r) name := pathVar(r, "name") var req serviceRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) return } svc := &slessv1alpha1.Service{} if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc); err != nil { if errors.IsNotFound(err) { writeJSON(w, http.StatusNotFound, errResp("service not found")) return } writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } 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 } // timeout_sec: 0 = нет ограничения (допустимо), отрицательное — ошибка. if req.TimeoutSec < 0 || req.TimeoutSec > 900 { writeJSON(w, http.StatusBadRequest, errResp("timeout_sec must be 0 (no limit) or 1–900")) return } svc.Spec.Runtime = req.Runtime svc.Spec.Entrypoint = req.Entrypoint svc.Spec.MemoryMB = req.MemoryMB svc.Spec.TimeoutSec = req.TimeoutSec svc.Spec.Env = req.Env if req.S3Bucket != "" { svc.Spec.S3Bucket = req.S3Bucket } if req.S3Key != "" { svc.Spec.S3Key = req.S3Key } if err := h.K8s.Update(r.Context(), svc); err != nil { if errors.IsInvalid(err) { writeJSON(w, http.StatusBadRequest, errResp("invalid service spec: "+err.Error())) return } writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } writeJSON(w, http.StatusOK, svcToResponse(svc)) } // DeleteService — DELETE /v1/namespaces/{namespace}/services/{name} func (h *Handler) DeleteService(w http.ResponseWriter, r *http.Request) { ns := namespace(r) name := pathVar(r, "name") svc := &slessv1alpha1.Service{} if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc); err != nil { if errors.IsNotFound(err) { writeJSON(w, http.StatusNotFound, errResp("service not found")) return } writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } if err := h.K8s.Delete(r.Context(), svc); err != nil { writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } w.WriteHeader(http.StatusNoContent) } // UploadServiceCode — POST /v1/namespaces/{namespace}/services/{name}/upload // Принимает multipart/form-data с полем "code" (zip архив с кодом сервиса). // Обновляет Service CRD — оператор запустит kaniko и затем задеплоит Deployment. // Логика идентична UploadCode (functions), но работает с Service CRD. func (h *Handler) UploadServiceCode(w http.ResponseWriter, r *http.Request) { ns := namespace(r) name := pathVar(r, "name") svc := &slessv1alpha1.Service{} if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, svc); err != nil { if errors.IsNotFound(err) { writeJSON(w, http.StatusNotFound, errResp("service not found")) return } writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } if err := r.ParseMultipartForm(32 << 20); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid multipart form: "+err.Error())) return } file, _, err := r.FormFile("code") if err != nil { writeJSON(w, http.StatusBadRequest, errResp(`field "code" is required (zip file)`)) return } defer file.Close() zipData, err := io.ReadAll(file) if err != nil { writeJSON(w, http.StatusInternalServerError, errResp("read upload: "+err.Error())) return } buf, err := builder.PrepareContext(zipData, svc.Spec.Runtime) if err != nil { writeJSON(w, http.StatusBadRequest, errResp("prepare build context: "+err.Error())) return } // Версия = sha256(загруженный zip). Одинаковый код → одинаковый s3Key → cache hit. // Изменился код → новый хеш → новый build. zipHash := sha256.Sum256(zipData) version := fmt.Sprintf("%x", zipHash[:])[:16] s3Key, err := h.S3.UploadContext(r.Context(), ns, name, version, buf, int64(buf.Len())) if err != nil { writeJSON(w, http.StatusInternalServerError, errResp("upload to S3: "+err.Error())) return } patch := client.MergeFrom(svc.DeepCopy()) svc.Spec.S3Key = s3Key svc.Spec.S3Bucket = h.S3.Bucket() if err := h.K8s.Patch(r.Context(), svc, patch); err != nil { writeJSON(w, http.StatusInternalServerError, errResp("update service: "+err.Error())) return } writeJSON(w, http.StatusOK, map[string]string{ "s3_key": s3Key, "phase": string(slessv1alpha1.ServicePhasePending), "message": "build queued", }) }