Files
sless/internal/api/handler/services.go
T

412 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Создано: 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 1900"))
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 1900"))
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",
})
}