feat(job): merge sless_function into sless_job — self-contained build+run

- FunctionJobSpec: убран FunctionRef, добавлены Runtime/Entrypoint/Env/S3Key/MemoryMB/TimeoutSec
- FunctionJobStatus: новый ImageRef, новая фаза Building
- FunctionJobReconciler: Building фаза (kaniko), убрана зависимость от Function CRD
  Builder+OperatorNamespace как поля struct; аннотация sless.kube5s.ru/build-job guard
- main.go: Builder+OperatorNamespace переданы в FunctionJobReconciler
- jobs.go handler: jobRequest/jobResponse без FunctionRef; новый UploadJobCode handler
- router.go: /jobs/{name}/upload маршрут
- client.go: JobRequest/JobResponse обновлены; UploadJobCode; uploadCodeToURL общий хелпер
- job_resource.go: полная переработка — источник/среда встроены в JobModel, ModifyPlan,
  Create с upload, wait_timeout_sec=900 по умолчанию (kaniko + выполнение)
- examples/POSTGRES/functions.tf: раскомментирован, sless_function удалён,
  sless_job самодостаточен (inline source_dir/runtime/entrypoint/env_vars)
This commit is contained in:
Naeel
2026-03-20 21:27:35 +03:00
parent 1b3c8376c0
commit 8ca8faedd1
12 changed files with 569 additions and 185 deletions
+110 -19
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-08
// Изменено: 2026-03-20 (merge: FunctionJob теперь самодостаточен — убран FunctionRef, добавлены Runtime/Entrypoint/Env)
// jobs.go — CRUD handlers для FunctionJob CRD.
// Создаёт/читает/удаляет k8s FunctionJob ресурсы.
// Namespace берётся из URL: /v1/namespaces/{namespace}/jobs/{name}
@@ -7,20 +7,29 @@ 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"
)
// jobRequest — тело POST /v1/namespaces/{ns}/jobs
type jobRequest struct {
Name string `json:"name"`
FunctionRef string `json:"function"`
EventJSON string `json:"event_json,omitempty"`
Name string `json:"name"`
Runtime string `json:"runtime"`
Entrypoint string `json:"entrypoint"`
MemoryMB int32 `json:"memory_mb,omitempty"`
TimeoutSec int32 `json:"timeout_sec,omitempty"`
Env map[string]string `json:"env_vars,omitempty"`
S3Bucket string `json:"s3_bucket,omitempty"`
S3Key string `json:"s3_key,omitempty"`
EventJSON string `json:"event_json,omitempty"`
// RunID — идентификатор запуска. 0 = создать без запуска, >0 = запустить.
RunID int64 `json:"run_id"`
}
@@ -29,10 +38,12 @@ type jobRequest struct {
type jobResponse struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
FunctionRef string `json:"function"`
Runtime string `json:"runtime"`
Entrypoint string `json:"entrypoint"`
EventJSON string `json:"event_json"`
RunID int64 `json:"run_id"`
Phase string `json:"phase"`
ImageRef string `json:"image_ref,omitempty"`
JobName string `json:"job_name,omitempty"`
StartTime string `json:"start_time,omitempty"`
CompletionTime string `json:"completion_time,omitempty"`
@@ -42,14 +53,16 @@ type jobResponse struct {
// jobToResponse конвертирует FunctionJob CR → jobResponse.
func jobToResponse(j *slessv1alpha1.FunctionJob) jobResponse {
r := jobResponse{
Name: j.Name,
Namespace: j.Namespace,
FunctionRef: j.Spec.FunctionRef,
EventJSON: j.Spec.EventJSON,
RunID: j.Spec.RunID,
Phase: string(j.Status.Phase),
JobName: j.Status.JobName,
Message: j.Status.Message,
Name: j.Name,
Namespace: j.Namespace,
Runtime: j.Spec.Runtime,
Entrypoint: j.Spec.Entrypoint,
EventJSON: j.Spec.EventJSON,
RunID: j.Spec.RunID,
Phase: string(j.Status.Phase),
ImageRef: j.Status.ImageRef,
JobName: j.Status.JobName,
Message: j.Status.Message,
}
if j.Status.StartTime != nil {
r.StartTime = j.Status.StartTime.UTC().Format("2006-01-02T15:04:05Z")
@@ -61,7 +74,7 @@ func jobToResponse(j *slessv1alpha1.FunctionJob) jobResponse {
}
// CreateJob — POST /v1/namespaces/{namespace}/jobs
// Создаёт FunctionJob CR. Оператор запустит k8s Job асинхронно.
// Создаёт FunctionJob CR. Оператор запустит kaniko сборку и затем k8s Job асинхронно.
func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
@@ -74,8 +87,12 @@ func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, errResp("name is required"))
return
}
if req.FunctionRef == "" {
writeJSON(w, http.StatusBadRequest, errResp("function is required"))
if req.Runtime == "" {
writeJSON(w, http.StatusBadRequest, errResp("runtime is required"))
return
}
if req.Entrypoint == "" {
writeJSON(w, http.StatusBadRequest, errResp("entrypoint is required"))
return
}
if req.EventJSON == "" {
@@ -88,9 +105,15 @@ func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
Namespace: ns,
},
Spec: slessv1alpha1.FunctionJobSpec{
FunctionRef: req.FunctionRef,
EventJSON: req.EventJSON,
RunID: req.RunID,
Runtime: req.Runtime,
Entrypoint: req.Entrypoint,
MemoryMB: req.MemoryMB,
TimeoutSec: req.TimeoutSec,
Env: req.Env,
S3Bucket: req.S3Bucket,
S3Key: req.S3Key,
EventJSON: req.EventJSON,
RunID: req.RunID,
},
}
@@ -152,3 +175,71 @@ func (h *Handler) DeleteJob(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// UploadJobCode — POST /v1/namespaces/{namespace}/jobs/{name}/upload
// Принимает multipart/form-data с полем "code" (zip архив с кодом функции).
// Аналогично UploadCode для Function, но работает с FunctionJob CRD.
// После загрузки обновляет Spec.S3Key — контроллер начнёт kaniko сборку.
func (h *Handler) UploadJobCode(w http.ResponseWriter, r *http.Request) {
ns := namespace(r)
name := pathVar(r, "name")
// Читаем FunctionJob для получения runtime
fj := &slessv1alpha1.FunctionJob{}
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fj); err != nil {
if errors.IsNotFound(err) {
writeJSON(w, http.StatusNotFound, errResp("job 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
buf, err := builder.PrepareContext(zipData, fj.Spec.Runtime)
if err != nil {
writeJSON(w, http.StatusBadRequest, errResp("prepare build context: "+err.Error()))
return
}
// Версия на основе timestamp — каждый upload → новый уникальный ключ в S3
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
}
// Обновляем FunctionJob CRD: новый s3Key → контроллер начнёт сборку
patch := client.MergeFrom(fj.DeepCopy())
fj.Spec.S3Key = s3Key
fj.Spec.S3Bucket = h.S3.Bucket()
if err := h.K8s.Patch(r.Context(), fj, patch); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp("update job: "+err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{
"s3_key": s3Key,
"phase": string(slessv1alpha1.FunctionJobPhasePending),
"message": "build queued",
})
}