// Изменено: 2026-03-11 // upload.go — обработчик загрузки кода функции. // Принимает zip от пользователя, вызывает builder.PrepareContext (Dockerfile + tar.gz), // кладёт результат в S3 и обновляет Function CRD чтобы контроллер запустил kaniko. // // Разделение ответственностей: // upload.go — HTTP: принять zip, сохранить в S3, обновить CRD. // builder/context.go — Build: zip+runtime → tar.gz+Dockerfile для kaniko. package handler import ( "crypto/sha256" "fmt" "io" "net/http" "k8s.io/apimachinery/pkg/api/errors" "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" ) // UploadCode — POST /v1/namespaces/{namespace}/functions/{name}/upload // Принимает multipart/form-data с полем "code" (zip архив с кодом функции). // Генерирует Dockerfile, пакует tar.gz, загружает в S3, обновляет Function CRD. func (h *Handler) UploadCode(w http.ResponseWriter, r *http.Request) { ns := namespace(r) name := pathVar(r, "name") // Проверяем что Function CRD существует и получаем runtime 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 } // Лимит 32MB на загрузку кода функции 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 } // Готовим build context: Dockerfile + tar.gz для kaniko. // Знание о runtime образах и структуре контекста — в builder.PrepareContext, не здесь. buf, err := builder.PrepareContext(zipData, fn.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 } // Обновляем Function CRD: новый s3Key и bucket → контроллер запустит kaniko. // Используем Patch вместо Update чтобы избежать конфликта resourceVersion: // между Get() выше и Update() контроллер может изменить статус объекта. // MergePatch обновляет только указанные поля, не требует точного resourceVersion. patch := client.MergeFrom(fn.DeepCopy()) fn.Spec.S3Key = s3Key fn.Spec.S3Bucket = h.S3.Bucket() if err := h.K8s.Patch(r.Context(), fn, patch); err != nil { writeJSON(w, http.StatusInternalServerError, errResp("update function: "+err.Error())) return } writeJSON(w, http.StatusOK, map[string]string{ "s3_key": s3Key, "phase": string(slessv1alpha1.FunctionPhasePending), "message": "build queued", }) }