412 lines
17 KiB
Go
412 lines
17 KiB
Go
// Создано: 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",
|
||
})
|
||
}
|