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:
@@ -65,3 +65,7 @@ plan.out
|
|||||||
sless-plan
|
sless-plan
|
||||||
examples/.git
|
examples/.git
|
||||||
event-dispatcher
|
event-dispatcher
|
||||||
|
|
||||||
|
# build artifacts
|
||||||
|
/sless
|
||||||
|
examples/POSTGRES/stress_log*.txt
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Изменено: 2026-03-08
|
// Изменено: 2026-03-20 (merge sless_function+sless_job: FunctionJobSpec самодостаточен,
|
||||||
|
// больше не требует отдельного Function CRD)
|
||||||
// Описание CRD FunctionJob — одноразовый запуск функции.
|
// Описание CRD FunctionJob — одноразовый запуск функции.
|
||||||
// Отдельный ресурс (не Trigger) потому что семантика принципиально другая:
|
// Отдельный ресурс (не Trigger) потому что семантика принципиально другая:
|
||||||
// - Trigger: постоянно живёт, описывает КАК функцию вызывают (http/cron)
|
// - Trigger: постоянно живёт, описывает КАК функцию вызывают (http/cron)
|
||||||
@@ -14,6 +15,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// FunctionJobSpec — параметры одноразового запуска функции.
|
// FunctionJobSpec — параметры одноразового запуска функции.
|
||||||
|
// Самодостаточен: содержит всё для сборки образа и запуска Job.
|
||||||
|
// Отдельный Function CRD больше не требуется.
|
||||||
type FunctionJobSpec struct {
|
type FunctionJobSpec struct {
|
||||||
// RunID — идентификатор запуска. 0 = не запускать.
|
// RunID — идентификатор запуска. 0 = не запускать.
|
||||||
// Каждое ненулевое значение уникально идентифицирует запуск.
|
// Каждое ненулевое значение уникально идентифицирует запуск.
|
||||||
@@ -22,9 +25,35 @@ type FunctionJobSpec struct {
|
|||||||
// +kubebuilder:default=0
|
// +kubebuilder:default=0
|
||||||
RunID int64 `json:"runId"`
|
RunID int64 `json:"runId"`
|
||||||
|
|
||||||
// FunctionRef — имя Function ресурса в том же namespace
|
// --- Параметры функции (встроены, не нужен отдельный sless_function) ---
|
||||||
|
|
||||||
|
// Runtime — язык и версия выполнения (go1.23, python3.11, nodejs20)
|
||||||
|
// +kubebuilder:validation:Enum=go1.23;python3.11;nodejs20
|
||||||
// +kubebuilder:validation:Required
|
// +kubebuilder:validation:Required
|
||||||
FunctionRef string `json:"functionRef"`
|
Runtime string `json:"runtime"`
|
||||||
|
|
||||||
|
// Entrypoint — точка входа в код функции (например: handler.handle)
|
||||||
|
// +kubebuilder:validation:Required
|
||||||
|
Entrypoint string `json:"entrypoint"`
|
||||||
|
|
||||||
|
// S3Bucket — бакет S3 где хранится tar.gz контекст сборки
|
||||||
|
S3Bucket string `json:"s3Bucket,omitempty"`
|
||||||
|
|
||||||
|
// S3Key — ключ объекта в S3 (путь до tar.gz контекста)
|
||||||
|
S3Key string `json:"s3Key,omitempty"`
|
||||||
|
|
||||||
|
// MemoryMB — лимит памяти в мегабайтах (default: 128)
|
||||||
|
// +kubebuilder:default=128
|
||||||
|
MemoryMB int32 `json:"memoryMB,omitempty"`
|
||||||
|
|
||||||
|
// TimeoutSec — максимальное время выполнения в секундах (default: 30)
|
||||||
|
// +kubebuilder:default=30
|
||||||
|
TimeoutSec int32 `json:"timeoutSec,omitempty"`
|
||||||
|
|
||||||
|
// Env — переменные окружения, передаются в контейнер функции
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
|
||||||
|
// --- Job-специфичные параметры ---
|
||||||
|
|
||||||
// EventJSON — данные передаваемые в handle(event) в JSON формате.
|
// EventJSON — данные передаваемые в handle(event) в JSON формате.
|
||||||
// Если не задан — передаётся пустой объект {}.
|
// Если не задан — передаётся пустой объект {}.
|
||||||
@@ -37,8 +66,10 @@ type FunctionJobSpec struct {
|
|||||||
type FunctionJobPhase string
|
type FunctionJobPhase string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// FunctionJobPhasePending — ожидает пока Function станет Ready
|
// FunctionJobPhasePending — ожидает начала сборки или запуска
|
||||||
FunctionJobPhasePending FunctionJobPhase = "Pending"
|
FunctionJobPhasePending FunctionJobPhase = "Pending"
|
||||||
|
// FunctionJobPhaseBuilding — идёт сборка Docker образа через kaniko
|
||||||
|
FunctionJobPhaseBuilding FunctionJobPhase = "Building"
|
||||||
// FunctionJobPhaseRunning — k8s Job запущен, функция выполняется
|
// FunctionJobPhaseRunning — k8s Job запущен, функция выполняется
|
||||||
FunctionJobPhaseRunning FunctionJobPhase = "Running"
|
FunctionJobPhaseRunning FunctionJobPhase = "Running"
|
||||||
// FunctionJobPhaseSucceeded — функция успешно завершилась
|
// FunctionJobPhaseSucceeded — функция успешно завершилась
|
||||||
@@ -49,12 +80,16 @@ const (
|
|||||||
|
|
||||||
// FunctionJobStatus — наблюдаемое состояние (заполняет контроллер).
|
// FunctionJobStatus — наблюдаемое состояние (заполняет контроллер).
|
||||||
type FunctionJobStatus struct {
|
type FunctionJobStatus struct {
|
||||||
// Phase — текущая фаза: Pending, Running, Succeeded, Failed
|
// Phase — текущая фаза: Pending, Building, Running, Succeeded, Failed
|
||||||
Phase FunctionJobPhase `json:"phase,omitempty"`
|
Phase FunctionJobPhase `json:"phase,omitempty"`
|
||||||
|
|
||||||
// JobName — имя созданного k8s Job
|
// JobName — имя созданного k8s Job
|
||||||
JobName string `json:"jobName,omitempty"`
|
JobName string `json:"jobName,omitempty"`
|
||||||
|
|
||||||
|
// ImageRef — полный путь к собранному Docker образу в registry
|
||||||
|
// Заполняется после успешной сборки (фаза Building → Running).
|
||||||
|
ImageRef string `json:"imageRef,omitempty"`
|
||||||
|
|
||||||
// StartTime — время запуска k8s Job
|
// StartTime — время запуска k8s Job
|
||||||
StartTime *metav1.Time `json:"startTime,omitempty"`
|
StartTime *metav1.Time `json:"startTime,omitempty"`
|
||||||
|
|
||||||
@@ -67,7 +102,7 @@ type FunctionJobStatus struct {
|
|||||||
|
|
||||||
//+kubebuilder:object:root=true
|
//+kubebuilder:object:root=true
|
||||||
//+kubebuilder:subresource:status
|
//+kubebuilder:subresource:status
|
||||||
//+kubebuilder:printcolumn:name="Function",type=string,JSONPath=`.spec.functionRef`
|
//+kubebuilder:printcolumn:name="Runtime",type=string,JSONPath=`.spec.runtime`
|
||||||
//+kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
|
//+kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
|
||||||
//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
|
//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (in *FunctionJob) DeepCopyInto(out *FunctionJob) {
|
|||||||
*out = *in
|
*out = *in
|
||||||
out.TypeMeta = in.TypeMeta
|
out.TypeMeta = in.TypeMeta
|
||||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||||
out.Spec = in.Spec
|
in.Spec.DeepCopyInto(&out.Spec)
|
||||||
in.Status.DeepCopyInto(&out.Status)
|
in.Status.DeepCopyInto(&out.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +112,16 @@ func (in *FunctionJobList) DeepCopyObject() runtime.Object {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
// FunctionJobSpec содержит map[string]string Env — требует явного deep copy.
|
||||||
func (in *FunctionJobSpec) DeepCopyInto(out *FunctionJobSpec) {
|
func (in *FunctionJobSpec) DeepCopyInto(out *FunctionJobSpec) {
|
||||||
*out = *in
|
*out = *in
|
||||||
|
if in.Env != nil {
|
||||||
|
in, out := &in.Env, &out.Env
|
||||||
|
*out = make(map[string]string, len(*in))
|
||||||
|
for key, val := range *in {
|
||||||
|
(*out)[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionJobSpec.
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionJobSpec.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// Изменено: 2026-03-17 20:00 (bugfix: job-name label удалён в k8s 1.27+, split-brain cached client)
|
// Изменено: 2026-03-20 (merge sless_function+sless_job: FunctionJobReconciler самодостаточен)
|
||||||
// FunctionJobReconciler — контроллер одноразовых запусков функций.
|
// FunctionJobReconciler — контроллер одноразовых запусков функций.
|
||||||
// При создании FunctionJob:
|
// При создании FunctionJob с RunID>0:
|
||||||
// 1. Ждёт пока Function станет Ready
|
// 1. Запускает kaniko сборку образа (фаза Building) — больше не зависит от Function CRD
|
||||||
// 2. Создаёт k8s Job который запускает образ функции с CMD runner
|
// 2. После сборки создаёт k8s Job который запускает образ функции с CMD runner
|
||||||
// 3. Следит за завершением Job → обновляет статус (Succeeded/Failed)
|
// 3. Следит за завершением Job → обновляет статус (Succeeded/Failed)
|
||||||
//
|
//
|
||||||
// Почему отдельный ресурс (не Trigger type=job):
|
// Почему отдельный ресурс (не Trigger type=job):
|
||||||
@@ -31,14 +31,17 @@ import (
|
|||||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||||
|
|
||||||
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
|
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
|
||||||
|
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FunctionJobReconciler reconciles a FunctionJob object
|
// FunctionJobReconciler reconciles a FunctionJob object
|
||||||
type FunctionJobReconciler struct {
|
type FunctionJobReconciler struct {
|
||||||
client.Client
|
client.Client
|
||||||
Scheme *runtime.Scheme
|
Scheme *runtime.Scheme
|
||||||
RegistrySecret string // имя k8s Secret с docker credentials (для imagePullSecrets)
|
RegistrySecret string // имя k8s Secret с docker credentials (для imagePullSecrets)
|
||||||
KubeClient kubernetes.Interface // typed client для чтения логов подов (logs API недоступен через controller-runtime client)
|
KubeClient kubernetes.Interface // typed client для чтения логов подов (logs API недоступен через controller-runtime client)
|
||||||
|
Builder *builder.Builder // kaniko builder — собирает образ функции
|
||||||
|
OperatorNamespace string // namespace оператора — где запускаются kaniko job'ы (обычно "sless")
|
||||||
}
|
}
|
||||||
|
|
||||||
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functionjobs,verbs=get;list;watch;create;update;patch;delete
|
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functionjobs,verbs=get;list;watch;create;update;patch;delete
|
||||||
@@ -48,8 +51,6 @@ type FunctionJobReconciler struct {
|
|||||||
|
|
||||||
// Reconcile — основной цикл контроллера.
|
// Reconcile — основной цикл контроллера.
|
||||||
func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||||
logger := log.FromContext(ctx)
|
|
||||||
|
|
||||||
fj := &slessv1alpha1.FunctionJob{}
|
fj := &slessv1alpha1.FunctionJob{}
|
||||||
if err := r.Get(ctx, req.NamespacedName, fj); err != nil {
|
if err := r.Get(ctx, req.NamespacedName, fj); err != nil {
|
||||||
if errors.IsNotFound(err) {
|
if errors.IsNotFound(err) {
|
||||||
@@ -76,29 +77,28 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
return ctrl.Result{}, nil
|
return ctrl.Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем что Function существует и готова
|
// Фаза Building — ждём завершения kaniko Job
|
||||||
fn := &slessv1alpha1.Function{}
|
if fj.Status.Phase == slessv1alpha1.FunctionJobPhaseBuilding {
|
||||||
if err := r.Get(ctx, client.ObjectKey{Name: fj.Spec.FunctionRef, Namespace: fj.Namespace}, fn); err != nil {
|
return r.checkJobBuild(ctx, fj)
|
||||||
if errors.IsNotFound(err) {
|
|
||||||
fj.Status.Phase = slessv1alpha1.FunctionJobPhasePending
|
|
||||||
fj.Status.Message = "function not found: " + fj.Spec.FunctionRef
|
|
||||||
_ = r.Status().Update(ctx, fj)
|
|
||||||
return ctrl.Result{}, nil
|
|
||||||
}
|
|
||||||
return ctrl.Result{}, fmt.Errorf("get function: %w", err)
|
|
||||||
}
|
|
||||||
if fn.Status.Phase != slessv1alpha1.FunctionPhaseReady {
|
|
||||||
fj.Status.Phase = slessv1alpha1.FunctionJobPhasePending
|
|
||||||
fj.Status.Message = "waiting for function Ready (current: " + string(fn.Status.Phase) + ")"
|
|
||||||
_ = r.Status().Update(ctx, fj)
|
|
||||||
// RequeueAfter: опрашиваем каждые 15с пока Function не станет Ready.
|
|
||||||
return ctrl.Result{RequeueAfter: 15 * time.Second}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Нет ImageRef — нужно собрать образ сначала.
|
||||||
|
// Если аннотация build-job уже есть но phase не Building (рестарт контроллера),
|
||||||
|
// восстанавливаем фазу Building.
|
||||||
|
if fj.Status.ImageRef == "" {
|
||||||
|
if fj.Annotations["sless.kube5s.ru/build-job"] != "" {
|
||||||
|
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseBuilding
|
||||||
|
fj.Status.Message = "resuming build: " + fj.Annotations["sless.kube5s.ru/build-job"]
|
||||||
|
_ = r.Status().Update(ctx, fj)
|
||||||
|
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
|
||||||
|
}
|
||||||
|
return r.startJobBuild(ctx, fj)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Образ собран — создаём/синхронизируем k8s Job
|
||||||
deployNS := "sless-fn-" + fj.Namespace
|
deployNS := "sless-fn-" + fj.Namespace
|
||||||
jobName := fmt.Sprintf("job-%s-%s", fj.Name, fj.CreationTimestamp.Format("20060102150405"))
|
jobName := fmt.Sprintf("job-%s-%s", fj.Name, fj.CreationTimestamp.Format("20060102150405"))
|
||||||
|
|
||||||
// Если Job уже создан — проверяем его статус
|
|
||||||
existingJob := &batchv1.Job{}
|
existingJob := &batchv1.Job{}
|
||||||
if err := r.Get(ctx, client.ObjectKey{Name: jobName, Namespace: deployNS}, existingJob); err == nil {
|
if err := r.Get(ctx, client.ObjectKey{Name: jobName, Namespace: deployNS}, existingJob); err == nil {
|
||||||
return r.syncJobStatus(ctx, fj, existingJob)
|
return r.syncJobStatus(ctx, fj, existingJob)
|
||||||
@@ -106,14 +106,105 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
return ctrl.Result{}, fmt.Errorf("get job: %w", err)
|
return ctrl.Result{}, fmt.Errorf("get job: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаём k8s Job
|
return r.createRunJob(ctx, fj, deployNS, jobName)
|
||||||
// Используем образ функции напрямую, переопределяем CMD чтобы запустить runner
|
}
|
||||||
// вместо server.py/server.js — runner выполняет handle(event) один раз и выходит
|
|
||||||
|
// startJobBuild запускает kaniko сборку образа и переводит FunctionJob в фазу Building.
|
||||||
|
func (r *FunctionJobReconciler) startJobBuild(ctx context.Context, fj *slessv1alpha1.FunctionJob) (ctrl.Result, error) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
buildJobName, err := r.Builder.Build(ctx, r.OperatorNamespace, fj.Name, fj.Spec.S3Key)
|
||||||
|
if err != nil {
|
||||||
|
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed
|
||||||
|
fj.Status.Message = "failed to start build: " + err.Error()
|
||||||
|
_ = r.Status().Update(ctx, fj)
|
||||||
|
return ctrl.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аннотация build-job guard против повторного запуска при параллельных reconcile.
|
||||||
|
// Как только аннотация выставлена, следующий reconcile войдёт в checkJobBuild.
|
||||||
|
if fj.Annotations == nil {
|
||||||
|
fj.Annotations = map[string]string{}
|
||||||
|
}
|
||||||
|
fj.Annotations["sless.kube5s.ru/build-job"] = buildJobName
|
||||||
|
if err := r.Update(ctx, fj); err != nil {
|
||||||
|
return ctrl.Result{}, fmt.Errorf("update build annotation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseBuilding
|
||||||
|
fj.Status.Message = "building image: " + buildJobName
|
||||||
|
if err := r.Status().Update(ctx, fj); err != nil {
|
||||||
|
return ctrl.Result{}, fmt.Errorf("update status to building: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("started kaniko build for functionjob", "build-job", buildJobName, "functionjob", fj.Name)
|
||||||
|
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkJobBuild опрашивает статус kaniko Job.
|
||||||
|
// При успехе: сохраняет ImageRef в status, очищает Build Job, requeue → createRunJob.
|
||||||
|
// При ошибке: переводит FunctionJob в Failed с логами kaniko.
|
||||||
|
func (r *FunctionJobReconciler) checkJobBuild(ctx context.Context, fj *slessv1alpha1.FunctionJob) (ctrl.Result, error) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
|
buildJobName := fj.Annotations["sless.kube5s.ru/build-job"]
|
||||||
|
if buildJobName == "" {
|
||||||
|
fj.Status.Phase = slessv1alpha1.FunctionJobPhasePending
|
||||||
|
_ = r.Status().Update(ctx, fj)
|
||||||
|
return ctrl.Result{Requeue: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := r.Builder.JobStatus(ctx, buildJobName)
|
||||||
|
if err != nil {
|
||||||
|
return ctrl.Result{}, fmt.Errorf("check build job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case "running":
|
||||||
|
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
|
||||||
|
|
||||||
|
case "succeeded":
|
||||||
|
// Builder вычисляет imageRef детерминировано по namespace+name+s3Key
|
||||||
|
imageRef := r.Builder.ImageRef(r.OperatorNamespace, fj.Name, fj.Spec.S3Key)
|
||||||
|
fj.Status.ImageRef = imageRef
|
||||||
|
// Сбрасываем Phase чтобы следующий reconcile пошёл в createRunJob
|
||||||
|
fj.Status.Phase = slessv1alpha1.FunctionJobPhasePending
|
||||||
|
fj.Status.Message = ""
|
||||||
|
if err := r.Status().Update(ctx, fj); err != nil {
|
||||||
|
return ctrl.Result{}, fmt.Errorf("update imageref in status: %w", err)
|
||||||
|
}
|
||||||
|
_ = r.Builder.Cleanup(ctx, buildJobName)
|
||||||
|
logger.Info("build succeeded, queuing run job", "image", imageRef, "functionjob", fj.Name)
|
||||||
|
return ctrl.Result{Requeue: true}, nil
|
||||||
|
|
||||||
|
case "failed":
|
||||||
|
// Ищем поды kaniko по лейблу job-name в namespace оператора
|
||||||
|
logs := getJobPodOutput(ctx, r.KubeClient, r.OperatorNamespace, "job-name="+buildJobName)
|
||||||
|
msg := "build job failed"
|
||||||
|
if logs != "" {
|
||||||
|
msg = "build job failed:\n" + logs
|
||||||
|
}
|
||||||
|
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed
|
||||||
|
fj.Status.Message = msg
|
||||||
|
_ = r.Status().Update(ctx, fj)
|
||||||
|
return ctrl.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRunJob создаёт k8s Job для выполнения функции.
|
||||||
|
// Использует собранный образ из fj.Status.ImageRef.
|
||||||
|
func (r *FunctionJobReconciler) createRunJob(ctx context.Context, fj *slessv1alpha1.FunctionJob, deployNS, jobName string) (ctrl.Result, error) {
|
||||||
|
logger := log.FromContext(ctx)
|
||||||
eventJSON := fj.Spec.EventJSON
|
eventJSON := fj.Spec.EventJSON
|
||||||
if eventJSON == "" {
|
if eventJSON == "" {
|
||||||
eventJSON = "{}"
|
eventJSON = "{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
memMB := fj.Spec.MemoryMB
|
||||||
|
if memMB <= 0 {
|
||||||
|
memMB = 128
|
||||||
|
}
|
||||||
|
|
||||||
// runner запускается через env var SLESS_EVENT — безопаснее чем передавать в args
|
// runner запускается через env var SLESS_EVENT — безопаснее чем передавать в args
|
||||||
// (args видны в ps aux, env vars — нет)
|
// (args видны в ps aux, env vars — нет)
|
||||||
ttl := int32(600) // автоудаление Job через 10 мин после завершения
|
ttl := int32(600) // автоудаление Job через 10 мин после завершения
|
||||||
@@ -125,7 +216,6 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
Labels: map[string]string{
|
Labels: map[string]string{
|
||||||
"managed-by": "sless",
|
"managed-by": "sless",
|
||||||
"functionjob": fj.Name,
|
"functionjob": fj.Name,
|
||||||
"function": fn.Name,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Spec: batchv1.JobSpec{
|
Spec: batchv1.JobSpec{
|
||||||
@@ -138,31 +228,25 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
Labels: map[string]string{
|
Labels: map[string]string{
|
||||||
"managed-by": "sless",
|
"managed-by": "sless",
|
||||||
"functionjob": fj.Name,
|
"functionjob": fj.Name,
|
||||||
"function": fn.Name,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Spec: corev1.PodSpec{
|
Spec: corev1.PodSpec{
|
||||||
RestartPolicy: corev1.RestartPolicyNever,
|
RestartPolicy: corev1.RestartPolicyNever,
|
||||||
// Используем тот же образ что и Deployment функции
|
|
||||||
// runner.py/runner.js переопределяет CMD сервера
|
|
||||||
InitContainers: nil,
|
|
||||||
Containers: []corev1.Container{
|
Containers: []corev1.Container{
|
||||||
{
|
{
|
||||||
Name: "runner",
|
Name: "runner",
|
||||||
Image: fn.Status.ImageRef,
|
Image: fj.Status.ImageRef,
|
||||||
// Переопределяем точку входа: запускаем runner вместо server
|
Command: runtimeRunnerCommand(fj.Spec.Runtime),
|
||||||
// runner читает SLESS_EVENT и вызывает handle(event) один раз
|
|
||||||
Command: runtimeRunnerCommand(fn.Spec.Runtime),
|
|
||||||
Env: append(
|
Env: append(
|
||||||
append(fnEnvVars(fn), corev1.EnvVar{
|
append(fjEnvVars(fj), corev1.EnvVar{
|
||||||
Name: "SLESS_EVENT",
|
Name: "SLESS_EVENT",
|
||||||
Value: eventJSON,
|
Value: eventJSON,
|
||||||
}),
|
}),
|
||||||
goJobModeEnv(fn.Spec.Runtime)...,
|
goJobModeEnv(fj.Spec.Runtime)...,
|
||||||
),
|
),
|
||||||
Resources: corev1.ResourceRequirements{
|
Resources: corev1.ResourceRequirements{
|
||||||
Limits: corev1.ResourceList{
|
Limits: corev1.ResourceList{
|
||||||
corev1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dMi", fn.Spec.MemoryMB)),
|
corev1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dMi", memMB)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -188,10 +272,11 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
return ctrl.Result{}, fmt.Errorf("update functionjob status: %w", err)
|
return ctrl.Result{}, fmt.Errorf("update functionjob status: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("created job for functionjob", "job", jobName, "functionjob", fj.Name)
|
logger.Info("created run job for functionjob", "job", jobName, "functionjob", fj.Name)
|
||||||
return ctrl.Result{}, nil
|
return ctrl.Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// syncJobStatus читает статус k8s Job и обновляет FunctionJob.Status.
|
// syncJobStatus читает статус k8s Job и обновляет FunctionJob.Status.
|
||||||
// Если Job ещё выполняется — запрашивает повторный reconcile через 5 сек
|
// Если Job ещё выполняется — запрашивает повторный reconcile через 5 сек
|
||||||
// (Owns не работает кросс-неймспейсно, поэтому используем polling).
|
// (Owns не работает кросс-неймспейсно, поэтому используем polling).
|
||||||
@@ -282,13 +367,13 @@ print(json.dumps(result))
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fnEnvVars преобразует env vars из FunctionSpec в k8s EnvVar slice.
|
// fjEnvVars формирует k8s EnvVar из полей FunctionJobSpec.
|
||||||
// Включает SLESS_ENTRYPOINT чтобы runner.py/runner.js знал какую функцию вызывать.
|
// SLESS_ENTRYPOINT сообщает runner'у какую функцию вызывать.
|
||||||
func fnEnvVars(fn *slessv1alpha1.Function) []corev1.EnvVar {
|
func fjEnvVars(fj *slessv1alpha1.FunctionJob) []corev1.EnvVar {
|
||||||
result := []corev1.EnvVar{
|
result := []corev1.EnvVar{
|
||||||
{Name: "SLESS_ENTRYPOINT", Value: fn.Spec.Entrypoint},
|
{Name: "SLESS_ENTRYPOINT", Value: fj.Spec.Entrypoint},
|
||||||
}
|
}
|
||||||
for k, v := range fn.Spec.Env {
|
for k, v := range fj.Spec.Env {
|
||||||
result = append(result, corev1.EnvVar{Name: k, Value: v})
|
result = append(result, corev1.EnvVar{Name: k, Value: v})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -2,6 +2,48 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-03-20 — Merge: убрать sless_function как обязательный prerequisite для sless_job
|
||||||
|
|
||||||
|
### Контекст
|
||||||
|
|
||||||
|
`sless_job` ранее требовал `FunctionRef` — имя существующего `sless_function` из которого брался `ImageRef`.
|
||||||
|
Это создавало два отдельных ресурса для одной задачи (запустить код один раз):
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
resource "sless_function" "f" { ... } # build
|
||||||
|
resource "sless_job" "j" { function = sless_function.f.name ... } # run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Решение
|
||||||
|
|
||||||
|
Сделать `FunctionJobSpec` самодостаточным: встроить `Runtime/Entrypoint/Env/S3Key` и запускать
|
||||||
|
kaniko сборку непосредственно из FunctionJob-контроллера (новая фаза `Building`).
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
resource "sless_job" "j" {
|
||||||
|
runtime = "python3.11"
|
||||||
|
source_dir = "./code/fn"
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Почему НЕ удаляем sless_function
|
||||||
|
|
||||||
|
`sless_function` нужен для `sless_trigger` (type=cron/http) — они ссылаются на функцию.
|
||||||
|
Для триггеров образ должен жить вечно (не удаляться после запуска), и за ним следит Function CRD.
|
||||||
|
`sless_job` же — разовый запуск; после завершения Job удаляется, образ остаётся в registry.
|
||||||
|
|
||||||
|
### Изменения в State Machine FunctionJobReconciler
|
||||||
|
|
||||||
|
```
|
||||||
|
Было: Pending → (ждать Function.Ready) → Running → Succeeded/Failed
|
||||||
|
Стало: Pending → Building (kaniko) → Pending + ImageRef → Running → Succeeded/Failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Фаза `Building` охраняется аннотацией `sless.kube5s.ru/build-job` — идемпотентна при рестарте.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-03-19 — Go runtime v0.1.1: внешние зависимости через go.mod/go.sum
|
## 2026-03-19 — Go runtime v0.1.1: внешние зависимости через go.mod/go.sum
|
||||||
|
|
||||||
### Контекст
|
### Контекст
|
||||||
|
|||||||
+29
-1
@@ -1,9 +1,37 @@
|
|||||||
# Прогресс разработки
|
# Прогресс разработки
|
||||||
|
|
||||||
Последнее обновление: 2026-03-20 21:30
|
Последнее обновление: 2026-03-20 (merge: sless_function + sless_job → self-contained sless_job)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-03-20 — Merge: sless_function + sless_job → единый self-contained sless_job
|
||||||
|
|
||||||
|
### Цель
|
||||||
|
Убрать обязательную зависимость `sless_job` от `sless_function`. Раньше для запуска одноразового
|
||||||
|
джоба нужно было сначала создать `sless_function` (CRD + kaniko build), потом `sless_job`.
|
||||||
|
Теперь `sless_job` самодостаточен: содержит runtime/entrypoint/env/source_dir и сам запускает сборку.
|
||||||
|
|
||||||
|
### Изменённые файлы
|
||||||
|
|
||||||
|
| Файл | Что сделано |
|
||||||
|
|------|-------------|
|
||||||
|
| `api/v1alpha1/job_types.go` | Убран `FunctionRef`. Добавлены: `Runtime/Entrypoint/S3Bucket/S3Key/MemoryMB/TimeoutSec/Env map[string]string`. Фаза `Building`. `ImageRef` в status. |
|
||||||
|
| `api/v1alpha1/zz_generated.deepcopy.go` | DeepCopyInto для FunctionJobSpec: proper deep copy map[string]string Env |
|
||||||
|
| `controllers/functionjob_controller.go` | Полная переработка: убрана зависимость от Function CRD. Новые поля Builder+OperatorNamespace. Новая фаза Building (kaniko). Методы startJobBuild/checkJobBuild. |
|
||||||
|
| `main.go` | Передача `Builder: bldr` и `OperatorNamespace: "sless"` в FunctionJobReconciler |
|
||||||
|
| `internal/api/handler/jobs.go` | jobRequest/jobResponse без FunctionRef, с Runtime/Entrypoint/Env/S3Key. Новый handler UploadJobCode. |
|
||||||
|
| `internal/api/router.go` | Добавлен маршрут `/namespaces/{ns}/jobs/{name}/upload` |
|
||||||
|
| `terraform/provider/internal/client/client.go` | JobRequest/JobResponse без FunctionRef, с Runtime/Entrypoint/Env/ImageRef. UploadJobCode метод. Рефакторинг UploadCodeReader → uploadCodeToURL. |
|
||||||
|
| `terraform/provider/internal/resources/job_resource.go` | JobModel без Function, с Runtime/Entrypoint/EnvVars/SourceDir/CodeHash/ImageRef. ModifyPlan. Create с upload. Дефолт wait_timeout_sec=900. |
|
||||||
|
| `examples/POSTGRES/functions.tf` | Убран `sless_function.postgres_sql_runner_create_table`. `sless_job.postgres_table_init_job` теперь самодостаточен: inline source_dir/runtime/entrypoint/env_vars. |
|
||||||
|
|
||||||
|
### Статус
|
||||||
|
- ✅ Go код скомпилировался (controllers, main, internal/api)
|
||||||
|
- ✅ Terraform provider: нет ошибок компилятора
|
||||||
|
- ✅ functions.tf: раскомментирован и обновлён
|
||||||
|
- ⏳ Требует: кросс-компиляция provider → deploy в кластер → тест apply
|
||||||
|
|
||||||
|
|
||||||
## 2026-03-20 — Восстановление PostgreSQL и отладка провайдера nubes
|
## 2026-03-20 — Восстановление PostgreSQL и отладка провайдера nubes
|
||||||
|
|
||||||
### Что произошло
|
### Что произошло
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
// 2026-03-20 — удалены stress_* функции (oneshot/Job). Архив: git history.
|
// 2026-03-20 (merge: sless_function + старый sless_job объединены в один self-contained sless_job)
|
||||||
// 2026-03-20 — выделено из resources.tf: sless функции, сервисы и джобы.
|
// Теперь sless_job несёт в себе runtime/entrypoint/source_dir — не нужен отдельный sless_function.
|
||||||
// 2026-03-19 — миграция: sless_function (oneshot/Job) + sless_service (long-running Deployment).
|
// WaitJobDone таймаут 900s покрывает kaniko сборку (~5 мин) + выполнение SQL (~несколько сек).
|
||||||
// sless_trigger(type=http) удалены — HTTP URL теперь автоматически в sless_service.
|
|
||||||
// postgres_sql_runner_create_table остаётся sless_function (oneshot/Job).
|
|
||||||
// pg-info, pg-table-reader, pg-table-writer → sless_service.
|
|
||||||
// 2026-03-20 — все ресурсы закомментированы перед удалением инстанса PostgreSQL.
|
|
||||||
|
|
||||||
/*
|
# Одноразовый запуск: собирает образ через kaniko, выполняет SQL, завершается.
|
||||||
|
# Заменяет sless_function.postgres_sql_runner_create_table + sless_job.postgres_table_init_job.
|
||||||
# Служебная функция выполняет SQL-операторы из event_json.
|
resource "sless_job" "postgres_table_init_job" {
|
||||||
# Credentials берутся из locals (vault_secrets) — без хардкода.
|
name = "pg-create-table-job-main-v13"
|
||||||
# Для сверки хардкод остаётся в terraform.tfvars.
|
runtime = "python3.11"
|
||||||
resource "sless_function" "postgres_sql_runner_create_table" {
|
entrypoint = "sql_runner.run_sql"
|
||||||
name = "pg-create-table-runner"
|
memory_mb = 128
|
||||||
runtime = "python3.11"
|
timeout_sec = 30
|
||||||
entrypoint = "sql_runner.run_sql"
|
source_dir = "${path.module}/code/sql-runner"
|
||||||
memory_mb = 128
|
wait_timeout_sec = 900
|
||||||
timeout_sec = 30
|
run_id = 13
|
||||||
|
|
||||||
env_vars = {
|
env_vars = {
|
||||||
PGHOST = local.pg_host
|
PGHOST = local.pg_host
|
||||||
@@ -25,20 +21,8 @@ resource "sless_function" "postgres_sql_runner_create_table" {
|
|||||||
PGUSER = local.pg_username
|
PGUSER = local.pg_username
|
||||||
PGPASSWORD = local.pg_password
|
PGPASSWORD = local.pg_password
|
||||||
PGSSLMODE = "require"
|
PGSSLMODE = "require"
|
||||||
# Для сверки (должно совпадать с vault):
|
|
||||||
# PGUSER = var.pg_user
|
|
||||||
# PGPASSWORD = var.pg_password
|
|
||||||
}
|
}
|
||||||
|
|
||||||
source_dir = "${path.module}/code/sql-runner"
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "sless_job" "postgres_table_init_job" {
|
|
||||||
name = "pg-create-table-job-main-v13"
|
|
||||||
function = sless_function.postgres_sql_runner_create_table.name
|
|
||||||
wait_timeout_sec = 180
|
|
||||||
run_id = 13
|
|
||||||
|
|
||||||
event_json = jsonencode({
|
event_json = jsonencode({
|
||||||
statements = [
|
statements = [
|
||||||
"CREATE TABLE IF NOT EXISTS terraform_demo_table (id serial PRIMARY KEY, title text NOT NULL, created_at timestamp DEFAULT now())"
|
"CREATE TABLE IF NOT EXISTS terraform_demo_table (id serial PRIMARY KEY, title text NOT NULL, created_at timestamp DEFAULT now())"
|
||||||
@@ -49,8 +33,6 @@ resource "sless_job" "postgres_table_init_job" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Long-running сервис на NodeJS: возвращает версию PG-сервера и счётчик строк в таблице.
|
# Long-running сервис на NodeJS: возвращает версию PG-сервера и счётчик строк в таблице.
|
||||||
# Единственная функция примера на nodejs20 — проверка что JS runtime работает.
|
|
||||||
# URL автоматически: https://sless.kube5s.ru/fn/<namespace>/pg-info
|
|
||||||
resource "sless_service" "pg_info" {
|
resource "sless_service" "pg_info" {
|
||||||
name = "pg-info"
|
name = "pg-info"
|
||||||
runtime = "nodejs20"
|
runtime = "nodejs20"
|
||||||
@@ -72,10 +54,6 @@ resource "sless_service" "pg_info" {
|
|||||||
depends_on = [sless_job.postgres_table_init_job]
|
depends_on = [sless_job.postgres_table_init_job]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Long-running сервисы чтения и записи строк terraform_demo_table — в одном файле table_rw.py.
|
|
||||||
# list_rows (GET) — читает все строки; add_row (POST {title}) — вставляет строку.
|
|
||||||
# URL автоматически: https://sless.kube5s.ru/fn/<namespace>/pg-table-reader
|
|
||||||
# https://sless.kube5s.ru/fn/<namespace>/pg-table-writer
|
|
||||||
resource "sless_service" "postgres_table_reader" {
|
resource "sless_service" "postgres_table_reader" {
|
||||||
name = "pg-table-reader"
|
name = "pg-table-reader"
|
||||||
runtime = "python3.11"
|
runtime = "python3.11"
|
||||||
@@ -125,5 +103,3 @@ resource "sless_service" "postgres_table_writer" {
|
|||||||
output "table_writer_url" {
|
output "table_writer_url" {
|
||||||
value = sless_service.postgres_table_writer.url
|
value = sless_service.postgres_table_writer.url
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
|
||||||
|
|||||||
+110
-19
@@ -1,4 +1,4 @@
|
|||||||
// Изменено: 2026-03-08
|
// Изменено: 2026-03-20 (merge: FunctionJob теперь самодостаточен — убран FunctionRef, добавлены Runtime/Entrypoint/Env)
|
||||||
// jobs.go — CRUD handlers для FunctionJob CRD.
|
// jobs.go — CRUD handlers для FunctionJob CRD.
|
||||||
// Создаёт/читает/удаляет k8s FunctionJob ресурсы.
|
// Создаёт/читает/удаляет k8s FunctionJob ресурсы.
|
||||||
// Namespace берётся из URL: /v1/namespaces/{namespace}/jobs/{name}
|
// Namespace берётся из URL: /v1/namespaces/{namespace}/jobs/{name}
|
||||||
@@ -7,20 +7,29 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/api/errors"
|
"k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
|
||||||
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
|
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
|
// jobRequest — тело POST /v1/namespaces/{ns}/jobs
|
||||||
type jobRequest struct {
|
type jobRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
FunctionRef string `json:"function"`
|
Runtime string `json:"runtime"`
|
||||||
EventJSON string `json:"event_json,omitempty"`
|
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 — идентификатор запуска. 0 = создать без запуска, >0 = запустить.
|
||||||
RunID int64 `json:"run_id"`
|
RunID int64 `json:"run_id"`
|
||||||
}
|
}
|
||||||
@@ -29,10 +38,12 @@ type jobRequest struct {
|
|||||||
type jobResponse struct {
|
type jobResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
FunctionRef string `json:"function"`
|
Runtime string `json:"runtime"`
|
||||||
|
Entrypoint string `json:"entrypoint"`
|
||||||
EventJSON string `json:"event_json"`
|
EventJSON string `json:"event_json"`
|
||||||
RunID int64 `json:"run_id"`
|
RunID int64 `json:"run_id"`
|
||||||
Phase string `json:"phase"`
|
Phase string `json:"phase"`
|
||||||
|
ImageRef string `json:"image_ref,omitempty"`
|
||||||
JobName string `json:"job_name,omitempty"`
|
JobName string `json:"job_name,omitempty"`
|
||||||
StartTime string `json:"start_time,omitempty"`
|
StartTime string `json:"start_time,omitempty"`
|
||||||
CompletionTime string `json:"completion_time,omitempty"`
|
CompletionTime string `json:"completion_time,omitempty"`
|
||||||
@@ -42,14 +53,16 @@ type jobResponse struct {
|
|||||||
// jobToResponse конвертирует FunctionJob CR → jobResponse.
|
// jobToResponse конвертирует FunctionJob CR → jobResponse.
|
||||||
func jobToResponse(j *slessv1alpha1.FunctionJob) jobResponse {
|
func jobToResponse(j *slessv1alpha1.FunctionJob) jobResponse {
|
||||||
r := jobResponse{
|
r := jobResponse{
|
||||||
Name: j.Name,
|
Name: j.Name,
|
||||||
Namespace: j.Namespace,
|
Namespace: j.Namespace,
|
||||||
FunctionRef: j.Spec.FunctionRef,
|
Runtime: j.Spec.Runtime,
|
||||||
EventJSON: j.Spec.EventJSON,
|
Entrypoint: j.Spec.Entrypoint,
|
||||||
RunID: j.Spec.RunID,
|
EventJSON: j.Spec.EventJSON,
|
||||||
Phase: string(j.Status.Phase),
|
RunID: j.Spec.RunID,
|
||||||
JobName: j.Status.JobName,
|
Phase: string(j.Status.Phase),
|
||||||
Message: j.Status.Message,
|
ImageRef: j.Status.ImageRef,
|
||||||
|
JobName: j.Status.JobName,
|
||||||
|
Message: j.Status.Message,
|
||||||
}
|
}
|
||||||
if j.Status.StartTime != nil {
|
if j.Status.StartTime != nil {
|
||||||
r.StartTime = j.Status.StartTime.UTC().Format("2006-01-02T15:04:05Z")
|
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
|
// 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) {
|
func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
|
||||||
ns := namespace(r)
|
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"))
|
writeJSON(w, http.StatusBadRequest, errResp("name is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.FunctionRef == "" {
|
if req.Runtime == "" {
|
||||||
writeJSON(w, http.StatusBadRequest, errResp("function is required"))
|
writeJSON(w, http.StatusBadRequest, errResp("runtime is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Entrypoint == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errResp("entrypoint is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.EventJSON == "" {
|
if req.EventJSON == "" {
|
||||||
@@ -88,9 +105,15 @@ func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) {
|
|||||||
Namespace: ns,
|
Namespace: ns,
|
||||||
},
|
},
|
||||||
Spec: slessv1alpha1.FunctionJobSpec{
|
Spec: slessv1alpha1.FunctionJobSpec{
|
||||||
FunctionRef: req.FunctionRef,
|
Runtime: req.Runtime,
|
||||||
EventJSON: req.EventJSON,
|
Entrypoint: req.Entrypoint,
|
||||||
RunID: req.RunID,
|
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)
|
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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
|
|||||||
v1.HandleFunc("/namespaces/{namespace}/jobs", h.CreateJob).Methods(http.MethodPost)
|
v1.HandleFunc("/namespaces/{namespace}/jobs", h.CreateJob).Methods(http.MethodPost)
|
||||||
v1.HandleFunc("/namespaces/{namespace}/jobs/{name}", h.GetJob).Methods(http.MethodGet)
|
v1.HandleFunc("/namespaces/{namespace}/jobs/{name}", h.GetJob).Methods(http.MethodGet)
|
||||||
v1.HandleFunc("/namespaces/{namespace}/jobs/{name}", h.DeleteJob).Methods(http.MethodDelete)
|
v1.HandleFunc("/namespaces/{namespace}/jobs/{name}", h.DeleteJob).Methods(http.MethodDelete)
|
||||||
|
v1.HandleFunc("/namespaces/{namespace}/jobs/{name}/upload", h.UploadJobCode).Methods(http.MethodPost)
|
||||||
|
|
||||||
// Цепочка middleware: logging → (auth только для /v1/) → router
|
// Цепочка middleware: logging → (auth только для /v1/) → router
|
||||||
// /fn/ — без auth, /v1/ — с auth.
|
// /fn/ — без auth, /v1/ — с auth.
|
||||||
|
|||||||
@@ -178,10 +178,12 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if err = (&controllers.FunctionJobReconciler{
|
if err = (&controllers.FunctionJobReconciler{
|
||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
Scheme: mgr.GetScheme(),
|
Scheme: mgr.GetScheme(),
|
||||||
RegistrySecret: cfg.RegistrySecret,
|
RegistrySecret: cfg.RegistrySecret,
|
||||||
KubeClient: kubernetes.NewForConfigOrDie(mgr.GetConfig()),
|
KubeClient: kubernetes.NewForConfigOrDie(mgr.GetConfig()),
|
||||||
|
Builder: bldr,
|
||||||
|
OperatorNamespace: "sless",
|
||||||
}).SetupWithManager(mgr); err != nil {
|
}).SetupWithManager(mgr); err != nil {
|
||||||
log.Error("unable to create controller", "controller", "FunctionJob", "err", err)
|
log.Error("unable to create controller", "controller", "FunctionJob", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -298,6 +298,17 @@ func (c *Client) UploadCode(ctx context.Context, ns, name, zipPath string) error
|
|||||||
// UploadCodeReader — загружает код из произвольного io.Reader (например in-memory zip).
|
// UploadCodeReader — загружает код из произвольного io.Reader (например in-memory zip).
|
||||||
// filename используется только как имя файла в multipart-форме.
|
// filename используется только как имя файла в multipart-форме.
|
||||||
func (c *Client) UploadCodeReader(ctx context.Context, ns, name, filename string, r io.Reader) error {
|
func (c *Client) UploadCodeReader(ctx context.Context, ns, name, filename string, r io.Reader) error {
|
||||||
|
return c.uploadCodeToURL(ctx, fmt.Sprintf("%s/v1/namespaces/%s/functions/%s/upload", c.endpoint, ns, name), filename, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadJobCode — POST /v1/namespaces/{ns}/jobs/{name}/upload
|
||||||
|
// Аналогично UploadCodeReader но для FunctionJob — запускает kaniko через FunctionJob CRD.
|
||||||
|
func (c *Client) UploadJobCode(ctx context.Context, ns, name, filename string, r io.Reader) error {
|
||||||
|
return c.uploadCodeToURL(ctx, fmt.Sprintf("%s/v1/namespaces/%s/jobs/%s/upload", c.endpoint, ns, name), filename, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadCodeToURL — внутренний хелпер: пакует io.Reader в multipart и POST-ит по указанному URL.
|
||||||
|
func (c *Client) uploadCodeToURL(ctx context.Context, uploadURL, filename string, r io.Reader) error {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
mw := multipart.NewWriter(&buf)
|
mw := multipart.NewWriter(&buf)
|
||||||
fw, err := mw.CreateFormFile("code", filename)
|
fw, err := mw.CreateFormFile("code", filename)
|
||||||
@@ -309,8 +320,7 @@ func (c *Client) UploadCodeReader(ctx context.Context, ns, name, filename string
|
|||||||
}
|
}
|
||||||
mw.Close()
|
mw.Close()
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/v1/namespaces/%s/functions/%s/upload", c.endpoint, ns, name)
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, &buf)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -428,10 +438,17 @@ func (c *Client) UpdateTrigger(ctx context.Context, ns, name string, req Trigger
|
|||||||
// --- Job CRUD ---
|
// --- Job CRUD ---
|
||||||
|
|
||||||
// JobRequest — тело POST /v1/namespaces/{ns}/jobs
|
// JobRequest — тело POST /v1/namespaces/{ns}/jobs
|
||||||
|
// Изменено: 2026-03-20 (merge: убран FunctionRef, добавлены Runtime/Entrypoint/Env)
|
||||||
type JobRequest struct {
|
type JobRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
FunctionRef string `json:"function"`
|
Runtime string `json:"runtime"`
|
||||||
EventJSON string `json:"event_json,omitempty"`
|
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: 0 = создать без запуска, >0 = запустить
|
||||||
RunID int64 `json:"run_id"`
|
RunID int64 `json:"run_id"`
|
||||||
}
|
}
|
||||||
@@ -440,10 +457,12 @@ type JobRequest struct {
|
|||||||
type JobResponse struct {
|
type JobResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
FunctionRef string `json:"function"`
|
Runtime string `json:"runtime"`
|
||||||
|
Entrypoint string `json:"entrypoint"`
|
||||||
EventJSON string `json:"event_json"`
|
EventJSON string `json:"event_json"`
|
||||||
RunID int64 `json:"run_id"`
|
RunID int64 `json:"run_id"`
|
||||||
Phase string `json:"phase"`
|
Phase string `json:"phase"`
|
||||||
|
ImageRef string `json:"image_ref"`
|
||||||
JobName string `json:"job_name"`
|
JobName string `json:"job_name"`
|
||||||
StartTime string `json:"start_time"`
|
StartTime string `json:"start_time"`
|
||||||
CompletionTime string `json:"completion_time"`
|
CompletionTime string `json:"completion_time"`
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
// 2026-03-17 12:20
|
// 2026-03-20 (merge: убран FunctionRef, добавлены Runtime/Entrypoint/SourceDir — sless_job теперь самодостаточен)
|
||||||
// job_resource.go — Terraform ресурс sless_job.
|
// job_resource.go — Terraform ресурс sless_job.
|
||||||
//
|
//
|
||||||
// Lifecycle:
|
// Lifecycle:
|
||||||
//
|
//
|
||||||
// Create: POST /v1/namespaces/{ns}/jobs → WaitJobDone (10 мин)
|
// Create: POST /v1/namespaces/{ns}/jobs
|
||||||
// Блокирует terraform apply до завершения джоба (Succeeded/Failed).
|
// Если source_dir задан → zipDir → UploadJobCode → контроллер запускает kaniko
|
||||||
// Если Failed — terraform apply падает с ошибкой.
|
// WaitJobDone покрывает: Building (kaniko) + Running (функция) + Succeeded/Failed
|
||||||
// Если run_id=0 — джоб создаётся в k8s, но k8s Job не запускается.
|
// Если run_id=0 — джоб создаётся в k8s, но не запускается (phase=Skipped).
|
||||||
// Read: GET /v1/namespaces/{ns}/jobs/{name} → sync phase/timing в state
|
// Read: GET /v1/namespaces/{ns}/jobs/{name} → sync phase/timing в state
|
||||||
// Delete: DELETE /v1/namespaces/{ns}/jobs/{name}
|
// Delete: DELETE /v1/namespaces/{ns}/jobs/{name}
|
||||||
// Семантически no-op (джоб уже выполнен), но убирает CR из кластера.
|
|
||||||
//
|
//
|
||||||
// run_id: значение 0 = создать без запуска. >0 = запустить/перезапустить джоб.
|
// run_id: 0 = создать без запуска; >0 = запустить.
|
||||||
// run_id имеет RequiresReplace: изменение значения (1→2→3) триггерирует повторный запуск.
|
// RequiresReplace на run_id + code_hash: изменение кода или run_id всегда пересоздаёт джоб.
|
||||||
|
// wait_timeout_sec должен покрывать оба этапа: kaniko (~5 мин) + выполнение функции.
|
||||||
|
// По умолчанию 900 сек (15 мин).
|
||||||
package resources
|
package resources
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -34,11 +36,13 @@ import (
|
|||||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultWaitTimeoutSec — дефолтный таймаут ожидания завершения джоба (600 сек = 10 мин).
|
// defaultJobWaitTimeoutSec — дефолтный таймаут ожидания завершения sless_job.
|
||||||
// Пользователь может переопределить через wait_timeout_sec.
|
// Покрывает kaniko сборку (~5 мин) + выполнение функции (~остаток).
|
||||||
const defaultWaitTimeoutSec = 600
|
// Переопределяется через wait_timeout_sec.
|
||||||
|
const defaultJobWaitTimeoutSec = 900
|
||||||
|
|
||||||
var _ resource.Resource = &JobResource{}
|
var _ resource.Resource = &JobResource{}
|
||||||
|
var _ resource.ResourceWithModifyPlan = &JobResource{}
|
||||||
|
|
||||||
type JobResource struct {
|
type JobResource struct {
|
||||||
client *client.Client
|
client *client.Client
|
||||||
@@ -50,15 +54,24 @@ func NewJobResource() resource.Resource {
|
|||||||
|
|
||||||
// JobModel — модель состояния terraform для sless_job.
|
// JobModel — модель состояния terraform для sless_job.
|
||||||
type JobModel struct {
|
type JobModel struct {
|
||||||
Name types.String `tfsdk:"name"`
|
Name types.String `tfsdk:"name"`
|
||||||
Function types.String `tfsdk:"function"`
|
Runtime types.String `tfsdk:"runtime"`
|
||||||
|
Entrypoint types.String `tfsdk:"entrypoint"`
|
||||||
|
MemoryMB types.Int64 `tfsdk:"memory_mb"`
|
||||||
|
TimeoutSec types.Int64 `tfsdk:"timeout_sec"`
|
||||||
|
EnvVars types.Map `tfsdk:"env_vars"`
|
||||||
|
// source_dir — директория с исходниками. Провайдер сам упакует в zip и загрузит.
|
||||||
|
SourceDir types.String `tfsdk:"source_dir"`
|
||||||
|
// code_hash — SHA256 содержимого source_dir. Computed; при изменении RequiresReplace.
|
||||||
|
CodeHash types.String `tfsdk:"code_hash"`
|
||||||
EventJSON types.String `tfsdk:"event_json"`
|
EventJSON types.String `tfsdk:"event_json"`
|
||||||
// RunID: 0 = не запускать (Skipped), 1+ = запустить/перезапустить.
|
// RunID: 0 = не запускать (Skipped), 1+ = запустить/перезапустить.
|
||||||
// RequiresReplace: изменение = пересоздание FunctionJob → новый запуск.
|
// RequiresReplace: изменение = пересоздание FunctionJob → новый запуск.
|
||||||
RunID types.Int64 `tfsdk:"run_id"`
|
RunID types.Int64 `tfsdk:"run_id"`
|
||||||
// wait_timeout_sec — максимальное ожидание завершения джоба. Дефолт 600 сек.
|
// wait_timeout_sec — максимальное ожидание (kaniko + выполнение). Дефолт 900 сек.
|
||||||
WaitTimeoutSec types.Int64 `tfsdk:"wait_timeout_sec"`
|
WaitTimeoutSec types.Int64 `tfsdk:"wait_timeout_sec"`
|
||||||
Phase types.String `tfsdk:"phase"`
|
Phase types.String `tfsdk:"phase"`
|
||||||
|
ImageRef types.String `tfsdk:"image_ref"`
|
||||||
StartTime types.String `tfsdk:"start_time"`
|
StartTime types.String `tfsdk:"start_time"`
|
||||||
CompletionTime types.String `tfsdk:"completion_time"`
|
CompletionTime types.String `tfsdk:"completion_time"`
|
||||||
Message types.String `tfsdk:"message"`
|
Message types.String `tfsdk:"message"`
|
||||||
@@ -70,19 +83,56 @@ func (r *JobResource) Metadata(_ context.Context, req resource.MetadataRequest,
|
|||||||
|
|
||||||
func (r *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
func (r *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||||
resp.Schema = schema.Schema{
|
resp.Schema = schema.Schema{
|
||||||
MarkdownDescription: "Одноразовый запуск serverless функции. terraform apply блокируется до завершения джоба.",
|
MarkdownDescription: "Одноразовый запуск serverless функции. terraform apply блокируется до завершения. Включает kaniko сборку образа.",
|
||||||
Attributes: map[string]schema.Attribute{
|
Attributes: map[string]schema.Attribute{
|
||||||
// Все input-поля immutable — джоб нельзя "изменить", только пересоздать.
|
|
||||||
|
|
||||||
"name": schema.StringAttribute{
|
"name": schema.StringAttribute{
|
||||||
Required: true,
|
Required: true,
|
||||||
PlanModifiers: []planmodifier.String{
|
PlanModifiers: []planmodifier.String{
|
||||||
stringplanmodifier.RequiresReplace(),
|
stringplanmodifier.RequiresReplace(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"function": schema.StringAttribute{
|
"runtime": schema.StringAttribute{
|
||||||
Required: true,
|
Required: true,
|
||||||
MarkdownDescription: "Имя sless_function ресурса в том же namespace.",
|
MarkdownDescription: "Среда выполнения: python3.11, nodejs20, go1.23.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"entrypoint": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
MarkdownDescription: "Точка входа: module.function (например sql_runner.run_sql).",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"memory_mb": schema.Int64Attribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Default: int64default.StaticInt64(128),
|
||||||
|
MarkdownDescription: "Лимит оперативной памяти в MB. По умолчанию 128.",
|
||||||
|
},
|
||||||
|
"timeout_sec": schema.Int64Attribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Default: int64default.StaticInt64(30),
|
||||||
|
MarkdownDescription: "Таймаут выполнения функции в секундах. По умолчанию 30.",
|
||||||
|
},
|
||||||
|
"env_vars": schema.MapAttribute{
|
||||||
|
ElementType: types.StringType,
|
||||||
|
Optional: true,
|
||||||
|
MarkdownDescription: "Переменные окружения для функции.",
|
||||||
|
},
|
||||||
|
"source_dir": schema.StringAttribute{
|
||||||
|
Optional: true,
|
||||||
|
MarkdownDescription: "Директория с исходниками. Провайдер сам запакует zip и загрузит.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// code_hash — вычисляется автоматически из source_dir (ModifyPlan).
|
||||||
|
// RequiresReplace: если код изменился (hash != state) → пересоздать джоб.
|
||||||
|
"code_hash": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
PlanModifiers: []planmodifier.String{
|
PlanModifiers: []planmodifier.String{
|
||||||
stringplanmodifier.RequiresReplace(),
|
stringplanmodifier.RequiresReplace(),
|
||||||
},
|
},
|
||||||
@@ -93,29 +143,36 @@ func (r *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *
|
|||||||
PlanModifiers: []planmodifier.String{
|
PlanModifiers: []planmodifier.String{
|
||||||
stringplanmodifier.RequiresReplace(),
|
stringplanmodifier.RequiresReplace(),
|
||||||
},
|
},
|
||||||
}, // run_id: 0 = создать без запуска (Skipped), >0 = запустить.
|
},
|
||||||
|
// run_id: 0 = создать без запуска (Skipped), >0 = запустить.
|
||||||
// Изменение run_id (1→2→3...) триггерирует пересоздание = новый запуск.
|
// Изменение run_id (1→2→3...) триггерирует пересоздание = новый запуск.
|
||||||
"run_id": schema.Int64Attribute{
|
"run_id": schema.Int64Attribute{
|
||||||
Optional: true,
|
Optional: true,
|
||||||
Computed: true,
|
Computed: true,
|
||||||
Default: int64default.StaticInt64(0),
|
Default: int64default.StaticInt64(0),
|
||||||
MarkdownDescription: "0 = не запускать; >0 = запустить. Увеличьте run_id для повторного запуска джоба.",
|
MarkdownDescription: "0 = не запускать; >0 = запустить. Увеличьте run_id для повторного запуска.",
|
||||||
PlanModifiers: []planmodifier.Int64{
|
PlanModifiers: []planmodifier.Int64{
|
||||||
int64planmodifier.RequiresReplace(),
|
int64planmodifier.RequiresReplace(),
|
||||||
},
|
},
|
||||||
Validators: []validator.Int64{
|
Validators: []validator.Int64{
|
||||||
int64validator.AtLeast(0),
|
int64validator.AtLeast(0),
|
||||||
},
|
},
|
||||||
}, // wait_timeout_sec — сколько ждать завершения джоба. Увеличь если код долго работает (например миграция БД).
|
},
|
||||||
|
// wait_timeout_sec покрывает kaniko сборку + выполнение функции.
|
||||||
"wait_timeout_sec": schema.Int64Attribute{
|
"wait_timeout_sec": schema.Int64Attribute{
|
||||||
Optional: true,
|
Optional: true,
|
||||||
Computed: true,
|
Computed: true,
|
||||||
MarkdownDescription: "Таймаут ожидания завершения джоба в секундах. По умолчанию 600 (10 мин).",
|
Default: int64default.StaticInt64(defaultJobWaitTimeoutSec),
|
||||||
|
MarkdownDescription: "Таймаут ожидания (kaniko + выполнение) в секундах. По умолчанию 900.",
|
||||||
},
|
},
|
||||||
// Computed — заполняются после завершения джоба
|
// Computed — заполняются контроллером и финализируются после завершения
|
||||||
"phase": schema.StringAttribute{
|
"phase": schema.StringAttribute{
|
||||||
Computed: true,
|
Computed: true,
|
||||||
MarkdownDescription: "Фаза выполнения: Pending, Running, Succeeded, Failed.",
|
MarkdownDescription: "Фаза: Pending, Building, Running, Succeeded, Failed.",
|
||||||
|
},
|
||||||
|
"image_ref": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
MarkdownDescription: "Docker image собранный kaniko.",
|
||||||
},
|
},
|
||||||
"start_time": schema.StringAttribute{
|
"start_time": schema.StringAttribute{
|
||||||
Computed: true,
|
Computed: true,
|
||||||
@@ -161,24 +218,32 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re
|
|||||||
eventJSON = "{}"
|
eventJSON = "{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// run_id=0: создаём FunctionJob в k8s, но оператор не запустит k8s Job.
|
envMap, diags := mapToStringMap(ctx, plan.EnvVars)
|
||||||
// Пользователь может поменять run_id > 0 позже чтобы запустить.
|
resp.Diagnostics.Append(diags...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
runID := plan.RunID.ValueInt64()
|
runID := plan.RunID.ValueInt64()
|
||||||
createdJob, err := r.client.CreateJob(ctx, ns, client.JobRequest{
|
createdJob, err := r.client.CreateJob(ctx, ns, client.JobRequest{
|
||||||
Name: plan.Name.ValueString(),
|
Name: plan.Name.ValueString(),
|
||||||
FunctionRef: plan.Function.ValueString(),
|
Runtime: plan.Runtime.ValueString(),
|
||||||
EventJSON: eventJSON,
|
Entrypoint: plan.Entrypoint.ValueString(),
|
||||||
RunID: runID,
|
MemoryMB: int32(plan.MemoryMB.ValueInt64()),
|
||||||
|
TimeoutSec: int32(plan.TimeoutSec.ValueInt64()),
|
||||||
|
Env: envMap,
|
||||||
|
EventJSON: eventJSON,
|
||||||
|
RunID: runID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, client.ErrJobAlreadyExists) {
|
if errors.Is(err, client.ErrJobAlreadyExists) {
|
||||||
existingJob, getErr := r.client.GetJob(ctx, ns, plan.Name.ValueString())
|
existingJob, getErr := r.client.GetJob(ctx, ns, plan.Name.ValueString())
|
||||||
if getErr != nil {
|
if getErr != nil {
|
||||||
resp.Diagnostics.AddError("create job", fmt.Sprintf("job already exists and get existing failed: %s", getErr.Error()))
|
resp.Diagnostics.AddError("create job", fmt.Sprintf("job already exists and get failed: %s", getErr.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if existingJob == nil {
|
if existingJob == nil {
|
||||||
resp.Diagnostics.AddError("create job", "job already exists but cannot be read right after conflict")
|
resp.Diagnostics.AddError("create job", "job already exists but cannot be read after conflict")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
createdJob = existingJob
|
createdJob = existingJob
|
||||||
@@ -188,31 +253,30 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если RunID=0 — не ждём завершения, пишем state сразу
|
// Загружаем код если задан source_dir — контроллер начнёт kaniko сборку после upload
|
||||||
if runID == 0 {
|
if !plan.SourceDir.IsNull() && plan.SourceDir.ValueString() != "" {
|
||||||
// state: Namespace/Name/Function из plan, RunID=0, Phase=Skipped, остальное empty
|
zipData, hash, err := zipDir(plan.SourceDir.ValueString())
|
||||||
waitTimeoutSec := plan.WaitTimeoutSec
|
if err != nil {
|
||||||
if waitTimeoutSec.IsNull() || waitTimeoutSec.IsUnknown() || waitTimeoutSec.ValueInt64() <= 0 {
|
resp.Diagnostics.AddError("zip source_dir", err.Error())
|
||||||
waitTimeoutSec = types.Int64Value(defaultWaitTimeoutSec)
|
return
|
||||||
}
|
}
|
||||||
resp.Diagnostics.Append(resp.State.Set(ctx, JobModel{
|
if err := r.client.UploadJobCode(ctx, ns, plan.Name.ValueString(), "function.zip", bytes.NewReader(zipData)); err != nil {
|
||||||
Name: types.StringValue(plan.Name.ValueString()),
|
resp.Diagnostics.AddError("upload job code", err.Error())
|
||||||
Function: types.StringValue(plan.Function.ValueString()),
|
return
|
||||||
EventJSON: plan.EventJSON,
|
}
|
||||||
RunID: types.Int64Value(0),
|
plan.CodeHash = types.StringValue(hash)
|
||||||
WaitTimeoutSec: waitTimeoutSec,
|
}
|
||||||
Phase: types.StringValue(createdJob.Phase),
|
|
||||||
StartTime: types.StringValue(createdJob.StartTime),
|
// run_id=0: не ждём, пишем state сразу (job в состоянии Skipped)
|
||||||
CompletionTime: types.StringValue(createdJob.CompletionTime),
|
if runID == 0 {
|
||||||
Message: types.StringValue(createdJob.Message),
|
resp.Diagnostics.Append(resp.State.Set(ctx, jobToModel(plan, createdJob))...)
|
||||||
})...)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Блокируем apply до завершения джоба (Succeeded или Failed)
|
// Блокируем apply до завершения джоба (охватывает Building + Running → Succeeded/Failed)
|
||||||
waitSec := plan.WaitTimeoutSec.ValueInt64()
|
waitSec := plan.WaitTimeoutSec.ValueInt64()
|
||||||
if waitSec <= 0 {
|
if waitSec <= 0 {
|
||||||
waitSec = defaultWaitTimeoutSec
|
waitSec = defaultJobWaitTimeoutSec
|
||||||
}
|
}
|
||||||
j, err := r.client.WaitJobDone(ctx, ns, plan.Name.ValueString(), time.Duration(waitSec)*time.Second)
|
j, err := r.client.WaitJobDone(ctx, ns, plan.Name.ValueString(), time.Duration(waitSec)*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -236,7 +300,6 @@ func (r *JobResource) Read(ctx context.Context, req resource.ReadRequest, resp *
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if j == nil {
|
if j == nil {
|
||||||
// Джоб удалён вне terraform — убираем из state
|
|
||||||
resp.State.RemoveResource(ctx)
|
resp.State.RemoveResource(ctx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -244,10 +307,10 @@ func (r *JobResource) Read(ctx context.Context, req resource.ReadRequest, resp *
|
|||||||
resp.Diagnostics.Append(resp.State.Set(ctx, jobToModel(state, j))...)
|
resp.Diagnostics.Append(resp.State.Set(ctx, jobToModel(state, j))...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update не реализован — все поля поддерживают только RequiresReplace.
|
// Update не реализован — все input-поля имеют RequiresReplace.
|
||||||
// terraform-plugin-framework никогда не вызовет Update для этого ресурса.
|
// terraform-plugin-framework никогда не вызовет Update для этого ресурса.
|
||||||
func (r *JobResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) {
|
func (r *JobResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||||
resp.Diagnostics.AddError("update not supported", "sless_job does not support in-place updates")
|
resp.Diagnostics.AddError("update not supported", "sless_job does not support in-place updates; increment run_id to re-run")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *JobResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
func (r *JobResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||||
@@ -262,19 +325,49 @@ func (r *JobResource) Delete(ctx context.Context, req resource.DeleteRequest, re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// jobToModel конвертирует API-ответ → state модель.
|
// ModifyPlan вычисляет hash директории source_dir на фазе terraform plan.
|
||||||
|
// Если hash изменился относительно state — code_hash в plan будет другим,
|
||||||
|
// что вместе с RequiresReplace на code_hash триггерирует пересоздание джоба.
|
||||||
|
func (r *JobResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
|
||||||
|
if req.Plan.Raw.IsNull() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var plan JobModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if plan.SourceDir.IsNull() || plan.SourceDir.ValueString() == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, hash, err := zipDir(plan.SourceDir.ValueString())
|
||||||
|
if err != nil {
|
||||||
|
return // директория может не существовать при первом init — не фейлим plan
|
||||||
|
}
|
||||||
|
plan.CodeHash = types.StringValue(hash)
|
||||||
|
resp.Diagnostics.Append(resp.Plan.Set(ctx, plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobToModel конвертирует API-ответ + plan (для локальных полей) → state модель.
|
||||||
func jobToModel(plan JobModel, j *client.JobResponse) JobModel {
|
func jobToModel(plan JobModel, j *client.JobResponse) JobModel {
|
||||||
waitTimeoutSec := plan.WaitTimeoutSec
|
waitSec := plan.WaitTimeoutSec
|
||||||
if waitTimeoutSec.IsNull() || waitTimeoutSec.IsUnknown() || waitTimeoutSec.ValueInt64() <= 0 {
|
if waitSec.IsNull() || waitSec.IsUnknown() || waitSec.ValueInt64() <= 0 {
|
||||||
waitTimeoutSec = types.Int64Value(defaultWaitTimeoutSec)
|
waitSec = types.Int64Value(defaultJobWaitTimeoutSec)
|
||||||
}
|
}
|
||||||
return JobModel{
|
return JobModel{
|
||||||
Name: types.StringValue(j.Name),
|
Name: types.StringValue(j.Name),
|
||||||
Function: types.StringValue(j.FunctionRef),
|
Runtime: types.StringValue(j.Runtime),
|
||||||
|
Entrypoint: types.StringValue(j.Entrypoint),
|
||||||
|
MemoryMB: plan.MemoryMB,
|
||||||
|
TimeoutSec: plan.TimeoutSec,
|
||||||
|
EnvVars: plan.EnvVars,
|
||||||
|
SourceDir: plan.SourceDir,
|
||||||
|
CodeHash: plan.CodeHash,
|
||||||
EventJSON: plan.EventJSON,
|
EventJSON: plan.EventJSON,
|
||||||
RunID: types.Int64Value(j.RunID),
|
RunID: types.Int64Value(j.RunID),
|
||||||
WaitTimeoutSec: waitTimeoutSec,
|
WaitTimeoutSec: waitSec,
|
||||||
Phase: types.StringValue(j.Phase),
|
Phase: types.StringValue(j.Phase),
|
||||||
|
ImageRef: types.StringValue(j.ImageRef),
|
||||||
StartTime: types.StringValue(j.StartTime),
|
StartTime: types.StringValue(j.StartTime),
|
||||||
CompletionTime: types.StringValue(j.CompletionTime),
|
CompletionTime: types.StringValue(j.CompletionTime),
|
||||||
Message: types.StringValue(j.Message),
|
Message: types.StringValue(j.Message),
|
||||||
|
|||||||
Reference in New Issue
Block a user