feat: sless_service CRD + ServiceReconciler, RBAC fix, split postgres/functions.tf, operator v0.1.41

This commit is contained in:
Naeel
2026-03-20 13:03:12 +03:00
parent dc65f7ab8f
commit 680beb675b
19 changed files with 2448 additions and 693 deletions
+305
View File
@@ -0,0 +1,305 @@
// Создано: 2026-03-20 (function-service-split)
// services.go — CRUD handlers для Service CRD (sless_service).
// sless_service = long-running Deployment + URL. Каждый вызов проксируется к поду.
// Namespace берётся из URL: /v1/namespaces/{namespace}/services/{name}
package handler
import (
"encoding/json"
"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
}
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,
},
}
if err := h.K8s.Create(r.Context(), svc); err != nil {
if errors.IsAlreadyExists(err) {
// Повторяем логику function_handler: обрабатываем split-brain кеша.
// Если объект реально есть и не в фазе Failed — возвращаем 409.
existing := &slessv1alpha1.Service{}
getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
shouldRecreate := errors.IsNotFound(getErr) ||
(getErr == nil && existing.Status.Phase == slessv1alpha1.ServicePhaseFailed)
if shouldRecreate {
if getErr == nil {
_ = h.K8s.Delete(r.Context(), existing)
}
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
}
writeJSON(w, http.StatusConflict, errResp("service already exists"))
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
}
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 {
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) {
w.WriteHeader(http.StatusNoContent)
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
}
version := time.Now().Format("20060102150405")
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",
})
}